Compare commits

..

1 commit

Author SHA1 Message Date
6a9d2c4bcc
convert to markdown 2025-11-08 17:29:36 +11:00
79 changed files with 1400 additions and 24261 deletions

View file

@ -1,37 +0,0 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \
libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \
libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \
libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \
libxkbcommon0
- name: Install Poetry
run: |
pipx install poetry==1.8.3
/root/.local/bin/poetry --version
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install project deps (including test extras)
run: |
poetry install --with test
- name: Run test script
run: |
./tests.sh

View file

@ -1,27 +0,0 @@
name: Lint
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
black pyflakes3 vulture python3-bandit
- name: Run linters
run: |
black --diff --check bouquin/*
black --diff --check tests/*
pyflakes3 bouquin/*
pyflakes3 tests/*
vulture
bandit -s B110 -r bouquin/

View file

@ -1,26 +0,0 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .

1
.gitignore vendored
View file

@ -4,4 +4,3 @@ __pycache__
.pytest_cache
dist
.coverage
*.db

View file

@ -1,155 +1,3 @@
# 0.5.2
* Update icon again to remove background
* Adjust History icon and reorder toolbar items
* Try to address checkbox/bullet size issues (again)
* Fix HTML export of markdown (with newlines, tables and other styling preserved)
* Remove table tool
# 0.5.1
* Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
* Update icon
* Update French translations
* Improve size of flashing reminder dialog
# 0.5
* More Italian translations, thank you @mdaleo404
* Set locked status on window title when locked
* Don't exit on incorrect key, let it be tried again
* Make reminders be its own dataset rather than tied to current string.
* Add support for repeated reminders
* Make reminders be a feature that can be turned on and off
* Add syntax highlighting for code blocks (right-click to set it)
* Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
* Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
# 0.4.5
* Make it possible to delete revisions
* Make it possible to force-lock the screen even if idle timer hasn't tripped
* Add shortcuts for lock and unlock of screen
* Other misc bug fixes
# 0.4.4.1
* Adjust some widget heights/settings text wrap
* Adjust shortcuts
* History unicode symbol
* Icon in version dialog
# 0.4.4
* Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
* Moving unchecked TODOs now skips placing them on weekends.
* Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
* Check for new version / download new AppImage via the Help -> Version screen.
* Remove extra newline after headings
# 0.4.3
* Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
# 0.4.2
* Improve Statistics widget height
* Improve SaveDialog widget width
* Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
* Make it possible to change regular text size
* Refactored Settings dialog to use tabs to reduce its size
# 0.4.1
* Allow time log entries to be edited directly in their table cells
* Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)
* Add 'Close tab' nav item and shortcut
# 0.4
* Remove screenshot tool
* Improve width of bug report dialog
* Improve size of checkboxes
* Convert bullet - to actual unicode bullets
* Add alarm option to set reminders
* Add time logging and reporting
# 0.3.2
* Add weekday letters on left axis of Statistics page
* Allow clicking on a date in the Statistics heatmap and have it open that page
* Add the ability to choose the database path at startup
* Add in-app bug report functionality
# 0.3.1
* Make it possible to add a tag from the Tag Browser
* Add a statistics dialog with heatmap
* Remove export to .txt (just use .md)
* Restore link styling and clickability
# 0.3
* Introduce Tags
* Make translations dynamically detected from the locales dir rather than hardcoded
* Add Italian translations (thanks @mdaleo404)
* Add version information in the navigation
* Increase line spacing between lines (except for code blocks)
* Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
* Avoid second checkbox/bullet on second newline after first newline
* Avoid Home/left arrow jumping to the left side of a list symbol
* Various test additions/fixes
# 0.2.1.8
* Translate all strings, add French, add locale choice in settings
* Fix hiding status bar (including find bar) when locked
# 0.2.1.7
* Fix being able to set bold, italic and strikethrough at the same time.
* Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager
* Add AppImage
# 0.2.1.6
* Some code cleanup and more coverage
* Improve code block styling / escaping out of the block in various scenarios
# 0.2.1.5
* Go back to font size 10 (I might add a switcher later)
* Fix bug with not syncing the right calendar date on search (History item would then be wrong too)
# 0.2.1.4
* Increase font size of normal text
* Fix auto-save of a tab if we are moving to another tab and it has not yet saved
* DRY up some code
# 0.2.1.3
* Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line
* Fix code backticks to not show but still be able to type code easily
# 0.2.1.2
* Ensure tabs are ordered by calendar date
* Some other code cleanups
# 0.2.1.1
* Fix history preview pane to be in markdown
* Some other code cleanups
# 0.2.1
* Introduce tabs!
# 0.2.0.1
* Fix chomping images when TODO is typed and converts to a checkbox
# 0.2.0
* Switch back to Markdown editor

View file

@ -1,16 +1,9 @@
# Bouquin
<div align="center">
<img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/bouquin/icons/bouquin.svg" alt="Bouquin logo" width="240" />
</div>
## Introduction
Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher.
It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
also how long we spent on them.
Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@ -18,78 +11,42 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
To increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk unless the user configures it to be in the settings.
There is deliberately no network connectivity or syncing intended, other than the option to send a bug
report from within the app, or optionally to check for new versions to upgrade to.
There is deliberately no network connectivity or syncing intended.
## Screenshots
## Screenshot
### General view
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png" alt="Bouquin screenshot" /></a>
</div>
![Screenshot of Bouquin](./screenshot.png)
### History panes
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png" alt="Screenshot of Bouquin History Preview Pane" width="500" style="margin: 0 10px;" /></a>
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png" alt="Screenshot of Bouquin History Diff Pane" width="500" style="margin: 0 10px;" /></a>
</div>
![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
### Tags
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png" alt="Screenshot of Bouquin Tag Manager screen" width="500" style="margin: 0 10px;" /></a>
</div>
### Time Logging
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png" alt="Screenshot of Bouquin Time Log screens" width="500" style="margin: 0 10px;" /></a>
</div>
### Statistics
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png" alt="Bouquin statistics" /></a>
</div>
## Some of the features
## Features
* Data is encrypted at rest
* Encryption key is prompted for and never stored, unless user chooses to via Settings
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
* Automatic rendering of basic Markdown syntax
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
* Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert
* Text is Markdown with basic styling
* Images are supported
* Search all pages, or find text on current page
* Add and manage tags
* Search
* Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password)
* Export the database to json, html, csv, markdown or .sql (for sqlite3)
* Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
* Dark and light theme support
* Dark and light themes
* Automatically generate checkboxes when typing 'TODO'
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
* English, French and Italian locales provided
* Ability to set reminder alarms (which will be flashed as the reminder)
* Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup
## How to install
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
### From PyPi/pip
* `pip install bouquin`
### From AppImage
* Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
### From source
* Clone this repo or download the tarball from the releases page

View file

@ -1,6 +0,0 @@
[Desktop Entry]
Type=Application
Name=Bouquin
Exec=Bouquin.AppImage
Icon=bouquin
Categories=Office

View file

@ -1,121 +0,0 @@
from __future__ import annotations
import importlib.metadata
import requests
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QTextEdit,
QDialogButtonBox,
QMessageBox,
)
from . import strings
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"
class BugReportDialog(QDialog):
"""
Dialog to collect a bug report
"""
MAX_CHARS = 5000
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("report_a_bug"))
layout = QVBoxLayout(self)
header = QLabel(strings._("bug_report_explanation"))
header.setWordWrap(True)
layout.addWidget(header)
self.text_edit = QTextEdit()
self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
layout.addWidget(self.text_edit)
self.text_edit.textChanged.connect(self._enforce_max_length)
# Buttons: Cancel / Send
button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
button_box.accepted.connect(self._send)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setMinimumWidth(560)
self.text_edit.setFocus()
# ------------Helpers ------------ #
def _enforce_max_length(self):
text = self.text_edit.toPlainText()
if len(text) <= self.MAX_CHARS:
return
# Remember cursor position
cursor = self.text_edit.textCursor()
pos = cursor.position()
# Trim and restore without re-entering this slot
self.text_edit.blockSignals(True)
self.text_edit.setPlainText(text[: self.MAX_CHARS])
self.text_edit.blockSignals(False)
cursor.setPosition(pos)
self.text_edit.setTextCursor(cursor)
def _send(self):
text = self.text_edit.toPlainText().strip()
if not text:
QMessageBox.warning(
self,
strings._("report_a_bug"),
strings._("bug_report_empty"),
)
return
# Get current app version
version = importlib.metadata.version("bouquin")
payload: dict[str, str] = {
"message": text,
"version": version,
}
# POST as JSON
try:
resp = requests.post(
f"{BUG_REPORT_HOST}/{ROUTE}",
json=payload,
timeout=10,
)
except Exception as e:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f"\n{e}",
)
return
if resp.status_code == 201:
QMessageBox.information(
self,
strings._("report_a_bug"),
strings._("bug_report_sent_ok"),
)
self.accept()
else:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
)

View file

@ -1,367 +0,0 @@
from __future__ import annotations
import re
from typing import Optional, Dict
from PySide6.QtGui import QColor, QTextCharFormat, QFont
class CodeHighlighter:
"""Syntax highlighter for different programming languages."""
# Language keywords
KEYWORDS = {
"python": [
"False",
"None",
"True",
"and",
"as",
"assert",
"async",
"await",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"nonlocal",
"not",
"or",
"pass",
"print",
"raise",
"return",
"try",
"while",
"with",
"yield",
],
"javascript": [
"abstract",
"arguments",
"await",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"double",
"else",
"enum",
"eval",
"export",
"extends",
"false",
"final",
"finally",
"float",
"for",
"function",
"goto",
"if",
"implements",
"import",
"in",
"instanceof",
"int",
"interface",
"let",
"long",
"native",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"true",
"try",
"typeof",
"var",
"void",
"volatile",
"while",
"with",
"yield",
],
"php": [
"abstract",
"and",
"array",
"as",
"break",
"callable",
"case",
"catch",
"class",
"clone",
"const",
"continue",
"declare",
"default",
"die",
"do",
"echo",
"else",
"elseif",
"empty",
"enddeclare",
"endfor",
"endforeach",
"endif",
"endswitch",
"endwhile",
"eval",
"exit",
"extends",
"final",
"for",
"foreach",
"function",
"global",
"goto",
"if",
"implements",
"include",
"include_once",
"instanceof",
"insteadof",
"interface",
"isset",
"list",
"namespace",
"new",
"or",
"print",
"print_r",
"private",
"protected",
"public",
"require",
"require_once",
"return",
"static",
"syslog",
"switch",
"throw",
"trait",
"try",
"unset",
"use",
"var",
"while",
"xor",
"yield",
],
"bash": [
"if",
"then",
"echo",
"else",
"elif",
"fi",
"case",
"esac",
"for",
"select",
"while",
"until",
"do",
"done",
"in",
"function",
"time",
"coproc",
],
"html": [
"DOCTYPE",
"html",
"head",
"title",
"meta",
"link",
"style",
"script",
"body",
"div",
"span",
"p",
"a",
"img",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"form",
"input",
"button",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"br",
"hr",
],
"css": [
"color",
"background",
"background-color",
"border",
"margin",
"padding",
"width",
"height",
"font",
"font-size",
"font-weight",
"display",
"position",
"top",
"left",
"right",
"bottom",
"float",
"clear",
"overflow",
"z-index",
"opacity",
],
}
@staticmethod
def get_language_patterns(language: str) -> list:
"""Get highlighting patterns for a language."""
patterns = []
keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
if language.lower() in ["python", "bash", "php"]:
# Comments (#)
patterns.append((r"#.*$", "comment"))
if language.lower() in ["javascript", "php", "css"]:
# Comments (//)
patterns.append((r"//.*$", "comment"))
# Multi-line comments (/* */)
patterns.append((r"/\*.*?\*/", "comment"))
if language.lower() in ["html", "xml"]:
# HTML/XML tags
patterns.append((r"<[^>]+>", "tag"))
# HTML comments
patterns.append((r"<!--.*?-->", "comment"))
# Numbers
patterns.append((r"\b\d+\.?\d*\b", "number"))
# Keywords
for keyword in keywords:
patterns.append((r"\b" + keyword + r"\b", "keyword"))
# Do strings last so they override any of the above (e.g reserved keywords in strings)
# Strings (double quotes)
patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
# Strings (single quotes)
patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
return patterns
@staticmethod
def get_format_for_type(
format_type: str, base_format: QTextCharFormat
) -> QTextCharFormat:
"""Get text format for a specific syntax type."""
fmt = QTextCharFormat(base_format)
if format_type == "keyword":
fmt.setForeground(QColor(86, 156, 214)) # Blue
fmt.setFontWeight(QFont.Weight.Bold)
elif format_type == "string":
fmt.setForeground(QColor(206, 145, 120)) # Orange
elif format_type == "comment":
fmt.setForeground(QColor(106, 153, 85)) # Green
fmt.setFontItalic(True)
elif format_type == "number":
fmt.setForeground(QColor(181, 206, 168)) # Light green
elif format_type == "tag":
fmt.setForeground(QColor(78, 201, 176)) # Cyan
return fmt
class CodeBlockMetadata:
"""Stores metadata about code blocks (language, etc.) for a document."""
def __init__(self):
self._block_languages: Dict[int, str] = {} # block_number -> language
def set_language(self, block_number: int, language: str):
"""Set the language for a code block."""
self._block_languages[block_number] = language.lower()
def get_language(self, block_number: int) -> Optional[str]:
"""Get the language for a code block."""
return self._block_languages.get(block_number)
def serialize(self) -> str:
"""Serialize metadata to a string."""
# Store as JSON-like format in a comment at the end
if not self._block_languages:
return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->"
def deserialize(self, text: str):
"""Deserialize metadata from text."""
self._block_languages.clear()
# Look for metadata comment at the end
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
if match:
pairs = match.group(1).split(",")
for pair in pairs:
if ":" in pair:
block_num, lang = pair.split(":", 1)
try:
self._block_languages[int(block_num)] = lang
except ValueError:
pass

File diff suppressed because it is too large Load diff

View file

@ -17,14 +17,13 @@ from PySide6.QtWidgets import (
QTextEdit,
)
from . import strings
class FindBar(QWidget):
"""Widget for finding text in the Editor"""
# emitted when the bar is hidden (Esc/✕), so caller can refocus editor
closed = Signal()
closed = (
Signal()
) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
def __init__(
self,
@ -32,28 +31,23 @@ class FindBar(QWidget):
shortcut_parent: QWidget | None = None,
parent: QWidget | None = None,
):
super().__init__(parent)
self.editor = editor
# store how to get the current editor
self._editor_getter = editor if callable(editor) else (lambda: editor)
self.shortcut_parent = shortcut_parent
# UI (build ONCE)
# UI
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 0, 6, 0)
layout.addWidget(QLabel(strings._("find")))
layout.addWidget(QLabel("Find:"))
self.edit = QLineEdit(self)
self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
self.edit.setPlaceholderText("Type to search…")
layout.addWidget(self.edit)
self.case = QCheckBox(strings._("find_bar_match_case"), self)
self.case = QCheckBox("Match case", self)
layout.addWidget(self.case)
self.prevBtn = QPushButton(strings._("previous"), self)
self.nextBtn = QPushButton(strings._("next"), self)
self.prevBtn = QPushButton("Prev", self)
self.nextBtn = QPushButton("Next", self)
self.closeBtn = QPushButton("", self)
self.closeBtn.setFlat(True)
layout.addWidget(self.prevBtn)
@ -62,15 +56,11 @@ class FindBar(QWidget):
self.setVisible(False)
# Shortcut (press Esc to hide bar)
sp = (
self.shortcut_parent
if self.shortcut_parent is not None
else (self.parent() or self)
)
QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
# Shortcut escape key to close findBar
sp = shortcut_parent if shortcut_parent is not None else (parent or self)
self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
# Signals (connect ONCE)
# Signals
self.edit.returnPressed.connect(self.find_next)
self.edit.textChanged.connect(self._update_highlight)
self.case.toggled.connect(self._update_highlight)
@ -78,17 +68,10 @@ class FindBar(QWidget):
self.prevBtn.clicked.connect(self.find_prev)
self.closeBtn.clicked.connect(self.hide_bar)
@property
def editor(self) -> QTextEdit | None:
"""Get the current editor"""
return self._editor_getter()
# ----- Public API -----
def show_bar(self):
"""Show the bar, seed with current selection if sensible, focus the line edit."""
if not self.editor:
return
tc = self.editor.textCursor()
sel = tc.selectedText().strip()
if sel and "\u2029" not in sel: # ignore multi-paragraph selections
@ -172,8 +155,6 @@ class FindBar(QWidget):
self._update_highlight()
def _update_highlight(self):
if not self.editor:
return
txt = self.edit.text()
if not txt:
self._clear_highlight()
@ -202,5 +183,4 @@ class FindBar(QWidget):
self.editor.setExtraSelections(selections)
def _clear_highlight(self):
if self.editor:
self.editor.setExtraSelections([])
self.editor.setExtraSelections([])

View file

@ -1,88 +0,0 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout
class FlowLayout(QLayout):
def __init__(
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
):
super().__init__(parent)
self._items = []
self._hspace = hspacing
self._vspace = vspacing
self.setContentsMargins(margin, margin, margin, margin)
def addItem(self, item):
self._items.append(item)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index):
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def count(self):
return len(self._items)
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width: int) -> int:
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self) -> QSize:
return self.minimumSize()
def minimumSize(self) -> QSize:
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
left, top, right, bottom = self.getContentsMargins()
size += QSize(left + right, top + bottom)
return size
def _do_layout(self, rect: QRect, test_only: bool) -> int:
x = rect.x()
y = rect.y()
line_height = 0
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
x = effective_rect.x()
y = effective_rect.y()
max_right = effective_rect.right()
for item in self._items:
wid = item.widget()
if wid is None or not wid.isVisible():
continue
space_x = self._hspace
space_y = self._vspace
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > max_right and line_height > 0:
# Wrap
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom

Binary file not shown.

View file

@ -1,93 +0,0 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -13,11 +13,8 @@ from PySide6.QtWidgets import (
QMessageBox,
QTextBrowser,
QTabWidget,
QAbstractItemView,
)
from . import strings
def _markdown_to_text(s: str) -> str:
"""Convert markdown to plain text for diff comparison."""
@ -46,9 +43,7 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _markdown_to_text(old_md).splitlines()
b = _markdown_to_text(new_md).splitlines()
ud = difflib.unified_diff(
a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm=""
)
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
lines = []
for line in ud:
if line.startswith("+") and not line.startswith("+++"):
@ -72,7 +67,7 @@ class HistoryDialog(QDialog):
def __init__(self, db, date_iso: str, parent=None):
super().__init__(parent)
self.setWindowTitle(f"{strings._('history')}{date_iso}")
self.setWindowTitle(f"History{date_iso}")
self._db = db
self._date = date_iso
self._versions = [] # list[dict] from DB
@ -83,7 +78,6 @@ class HistoryDialog(QDialog):
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1)
@ -94,8 +88,8 @@ class HistoryDialog(QDialog):
self.preview.setOpenExternalLinks(True)
self.diff = QTextBrowser()
self.diff.setOpenExternalLinks(False)
self.tabs.addTab(self.preview, strings._("history_dialog_preview"))
self.tabs.addTab(self.diff, strings._("history_dialog_diff"))
self.tabs.addTab(self.preview, "Preview")
self.tabs.addTab(self.diff, "Diff")
self.tabs.setMinimumSize(500, 650)
top.addWidget(self.tabs, 2)
@ -104,38 +98,39 @@ class HistoryDialog(QDialog):
# Buttons
row = QHBoxLayout()
row.addStretch(1)
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
self.btn_revert = QPushButton("Revert to Selected")
self.btn_revert.clicked.connect(self._revert)
self.btn_delete = QPushButton(strings._("history_dialog_delete"))
self.btn_delete.clicked.connect(self._delete)
self.btn_close = QPushButton(strings._("close"))
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert)
row.addWidget(self.btn_delete)
row.addWidget(self.btn_close)
root.addLayout(row)
self._load_versions()
# --- Data/UX helpers ---
def _load_versions(self):
# [{id,version_no,created_at,note,is_current}]
self._versions = self._db.list_versions(self._date)
def _fmt_local(self, iso_utc: str) -> str:
"""
Convert UTC in the database to user's local tz
"""
dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
local = dt.astimezone()
return local.strftime("%Y-%m-%d %H:%M:%S %Z")
def _load_versions(self):
self._versions = self._db.list_versions(
self._date
) # [{id,version_no,created_at,note,is_current}]
self._current_id = next(
(v["id"] for v in self._versions if v["is_current"]), None
)
self.list.clear()
for v in self._versions:
created_at = datetime.fromisoformat(
v["created_at"].replace("Z", "+00:00")
).astimezone()
created_at_local = created_at.strftime("%Y-%m-%d %H:%M:%S %Z")
label = f"v{v['version_no']}{created_at_local}"
label = f"v{v['version_no']}{self._fmt_local(v['created_at'])}"
if v.get("note"):
label += f" · {v['note']}"
if v["is_current"]:
label += " **(" + strings._("current") + ")**"
label += " **(current)**"
it = QListWidgetItem(label)
it.setData(Qt.UserRole, v["id"])
self.list.addItem(it)
@ -150,28 +145,31 @@ class HistoryDialog(QDialog):
@Slot()
def _on_select(self):
selected_items = self.list.selectedItems()
item = self.list.currentItem()
if not item or len(selected_items) > 1:
if not item:
self.preview.clear()
self.diff.clear()
self.btn_revert.setEnabled(False)
return
sel_id = item.data(Qt.UserRole)
# Preview selected as plain text (markdown)
sel = self._db.get_version(version_id=sel_id)
self.preview.setMarkdown(sel["content"])
# Show markdown as plain text with monospace font for better readability
self.preview.setPlainText(sel["content"])
self.preview.setStyleSheet(
"font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
)
# Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
# Enable revert and delete buttons only if selecting a non-current version
# Enable revert only if selecting a non-current version
self.btn_revert.setEnabled(sel_id != self._current_id)
self.btn_delete.setEnabled(sel_id != self._current_id)
@Slot()
def _revert(self):
item = self.list.currentItem()
if not item:
return
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
@ -179,24 +177,6 @@ class HistoryDialog(QDialog):
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(
self, strings._("history_dialog_revert_failed"), str(e)
)
QMessageBox.critical(self, "Revert failed", str(e))
return
self.accept()
@Slot()
def _delete(self):
selected_items = self.list.selectedItems()
for item in selected_items:
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
try:
self._db.delete_version(version_id=sel_id)
except Exception as e:
QMessageBox.critical(
self, strings._("history_dialog_delete_failed"), str(e)
)
return
return self._load_versions()

View file

@ -1,53 +0,0 @@
<svg
width="512"
height="512"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Book cover -->
<rect
x="116"
y="76"
width="280"
height="360"
rx="48"
ry="48"
fill="#FACC15"
/>
<!-- Book spine -->
<rect
x="116"
y="76"
width="64"
height="360"
rx="40"
ry="40"
fill="#F59E0B"
/>
<!-- Folded page corner (top-right triangle) -->
<path
d="M396 76 L356 76 L396 116 Z"
fill="#FEF9C3"
/>
<!-- Keyhole: circular top -->
<circle
cx="256"
cy="256"
r="34"
fill="#0F172A"
/>
<!-- Keyhole: stem -->
<rect
x="238"
y="290"
width="36"
height="90"
rx="12"
ry="12"
fill="#0F172A"
/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

View file

@ -1,107 +1,47 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QDialogButtonBox,
QFileDialog,
)
from . import strings
class KeyPrompt(QDialog):
def __init__(
self,
parent=None,
title: str = strings._("key_prompt_enter_key"),
message: str = strings._("key_prompt_enter_key"),
initial_db_path: str | Path | None = None,
show_db_change: bool = False,
title: str = "Enter key",
message: str = "Enter key",
):
"""
Prompt the user for the key required to decrypt the database.
Used when opening the app, unlocking the idle locked screen,
or when rekeying.
If show_db_change is true, also show a QFileDialog allowing to
select a database file, else the default from settings is used.
"""
super().__init__(parent)
self.setWindowTitle(title)
self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
v = QVBoxLayout(self)
v.addWidget(QLabel(message))
# DB chooser
self.path_edit: QLineEdit | None = None
if show_db_change:
path_row = QHBoxLayout()
self.path_edit = QLineEdit()
if self._db_path is not None:
self.path_edit.setText(str(self._db_path))
browse_btn = QPushButton(strings._("select_notebook"))
def _browse():
start_dir = str(self._db_path or "")
fname, _ = QFileDialog.getOpenFileName(
self,
strings._("select_notebook"),
start_dir,
"SQLCipher DB (*.db);;All files (*)",
)
if fname:
self._db_path = Path(fname)
if self.path_edit is not None:
self.path_edit.setText(fname)
browse_btn.clicked.connect(_browse)
path_row.addWidget(self.path_edit, 1)
path_row.addWidget(browse_btn)
v.addLayout(path_row)
# Key entry
self.key_entry = QLineEdit()
self.key_entry.setEchoMode(QLineEdit.Password)
v.addWidget(self.key_entry)
toggle = QPushButton(strings._("show"))
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.Password)
v.addWidget(self.edit)
toggle = QPushButton("Show")
toggle.setCheckable(True)
toggle.toggled.connect(
lambda c: self.key_entry.setEchoMode(
lambda c: self.edit.setEchoMode(
QLineEdit.Normal if c else QLineEdit.Password
)
)
v.addWidget(toggle)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
self.key_entry.setFocus()
self.resize(500, self.sizeHint().height())
def key(self) -> str:
return self.key_entry.text()
def db_path(self) -> Path | None:
"""Return the chosen DB path (or None if unchanged/not shown)."""
p = self._db_path
if self.path_edit is not None:
text = self.path_edit.text().strip()
if text:
p = Path(text)
return p
return self.edit.text()

View file

@ -1,109 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGQiioEBEAD2hJIaDsfkURHpA9KUXQQezeNhSiUcIheT3vP7Tb8nU2zkIgdy
gvwvuUcXKjUn22q+paqbQu+skYEjtLEFo59ZlS2VOQ6f9ukTGu2O6HWqFWncH3Vv
Pf0UeitNOoWi+qA14mtC7c/SxuHtMG4hmlHILGZg9mlSZfpt7oyczFtV7YG9toRe
gvyM8h2BRSi3EXigsymVMgpYcW3bESVxOnNJdNEFP8fKzR9Bu7rc99abRPm5p6gw
cYo9FAdLoiE8QcNU79hQ5UTAULWXFo3hduQfAs3y0f+g8FGJZUF40Gb8YJDtarRA
J7B9/XdfDNDZE00/QxV2gUGbLVTbVjqn6dKhEOTfuvSmfQxqNNy2a1ewpJrNnsvh
XGvSzZVLNy/c4CEROisRqDCa8xUb/snnHy7gGEuD5DXqQL3wnbTXu92N8gVxLegS
fr9NW2I6/eXWrlXhWJdP5ZH9yq7FVkWha2gTByP6bcxDBvQCzKyYg4JbY9bQDtJf
z7W2W9V6QHMiGJ9/ApfgTjKn0peiouGS8GGCPqLLyVGblEIJmSfEU+0BPq9PurRH
RR/T7E4wVi3bgOfj9G5Z8dMBWh5BzN7PqxQvO1lCx7ZZteNkt/wXglLHB0eghnD0
BCxuZ7lN12NW+lTf9s/kc0PS8YgZ0/AIFv45PHX1sVcxXizT49HQUbHa1wARAQAB
tBpNaWd1ZWwgSmFjcSA8bWlnQG1pZzUubmV0PokCVAQTAQoAPhYhBACugXwkoQwl
QEYanB183gI020WNBQJkIoqBAhsDBQkFo5qABQsJCAcDBRUKCQgLBRYCAwEAAh4B
AheAAAoJEB183gI020WN+2AQALJ58Qr4P3/lON50ulG/RgIYxXlPnyy4Ai1bDJiI
t3pLOWGQkGza6lw07rEh8Bs6w9sQ7WrpfzLRaYgqhfkBNbMtim8hRNZUuE/8O+v3
k9GRVYCe9RWazKhno+RljJy4TaqiqBeGxnryDJWxk8O4dXmQAnsFPF09xNpktgOC
mGbclA+rM8dY3bgq5wJ5Bh10zW4psfoAT1wFYX/oV19vlHbhRx3bavoWDS4lmXYv
oWy9xwacDVoZYcbGPif3xbMbttdKH7ijf+asM3wYUsIrHeOPdHl+YK45e6AGdjwL
mvp0P4YQo8Yk3yfH3L/km/no8rwcrPbk7+lX06x2GEjOiM2OIKAZYMZnL0BREgt4
XsD2hcQpuowxHmI2X2CHk8TnPhAXyNdX7Ss/geQ6Zx/q1Ts+mhhfQVa9AIRS+HDm
LURQRdZKBD1mB2hJsuF2WCyczuJ8jhBc+wSX/WXnQHLi2cG3OAC1udxrdDIckWb8
4CojEbk05cnMLR3dPV/g1JeXunib569RNTAijaTr39VRBZepYJX/sO46iag2+0A4
q41FgId2BwUS3GoyaIFZc5+MwLn65uYMgbIkfVlNkWEujoWV/aVLMrRa0udq4ZRE
ymPU8pfMhEWb3uvYCv+ed7sVxsVUMWeuQpyBQuPP1qlIzmsrEkRKryYH+ij4Vzri
OWvbuQINBGQiizkBEAC07TI3uqEUWKivXf5Mg1HnMAhpmv6yimr8PDfh3Dczy0eP
oCB6iq5wKCjYsp12E3kv3dcW4Ox8T+5U/B5ZP2lro63yeLSORUSz+jMq27rgtGmV
QFZNdKkzBzfPyzjKiZz4KaYE7Pn6v15In65SRqwqAXYUTkEoii+Ykk32qzZWIVCR
ixpRQGbBi+/XipONp8KCQANOSWSzTf8s7U1y4yhW1yCeUOK67LsSRlCtBpDWD7ki
MfX/nzSQyaXHDOrhkfVshU8eiln2Qf3mYg8gJmfFOb0zILhvCf3Sk312GtdxJo1m
B95TrDY8/7+1+l0wVrTq69tJXjQjBSmk1PBvNthSXCvuADnF8NxQlQuZtyI+rC4T
VInuLTr58YrmRIbGzOrFz+z6c532SB9F2PZvezjJ8LPDGCwW8dM6ADQxIw5cV0YE
hb5liFpeIX/NOnd1kus8Q6jyS0vzFqfgZC9kBFUTaXBM+mpDg1GYB4WS7baBQn3P
Z+7wvcN7VkfSBT2B79gJK0vfutJWBuK3p2435/KkD4PcAm6uBYL52b+Za06PQfgu
GaKxXRLREq/KCbYm4IKBkD8HRH9dmdd2U8YsApNWQ/oAHCfWvimhYUD9YOJimDwp
hX7FkaF/xHdi1/8hG8h2lok4cCtbaZPGXAUKuKHDhDFAI/OiIgv4nxq+A5kzfwAR
AQABiQRyBBgBCgAmFiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQiizkCGwIFCQWj
moACQAkQHXzeAjTbRY3BdCAEGQEKAB0WIQQ4BFZaXvpsEa/aDlNZs/DCQTXGqQUC
ZCKLOQAKCRBZs/DCQTXGqTv6D/9eFMA3ReSg1sfPsyEFj9JiJ3H3aOJX5R5/2xdI
QZLTjH0iapgGm3h8v+bFdr4+y3xWHpcaxBJsccyOZxzr0xjr+qt5t6OZrE+e1pQh
Hw/Kt7m5SiCmbGM6I3aECv8zU4EpGUf/FXLcaBaot4eR4uPRjBLatngzLw+5Mjk1
ZBjmyA5OaAqQzrDXPhFBItsSlHJeBOrpbzqxdjQi2AHD+L50itgfsoDOfVtmELZN
heW7xn83U2iqgu3bEq4Ug8lqh2KVBHELoxErQR+wTAIxgj/CwhVDQdrKhQ4ypbLh
O/oPlMmGFcBoMhCATNWitdqQUu7EHAECGyWCns8hm1OksqHMnbNhOzmRkl18UroZ
a1CJPFpeaEC25U37+yPEUiG4dJE8iiZAfyjv0AN1TbXzov5g9g/Xz+BmVALtOYBJ
fWKH/aTg5CU2GY9ts+bYDz+mli39h7FQQfcW+zjVWft2P4R7FvG0DBEJkbyw053R
++CEO1ARsMyygy2ukwkA06nYPlbaH5wEpQl2NV5PeYt66eU4epgL7y89/DhOSBig
JJJk+OASEh3o7rC/EkrlF/GQD8ZwO1oBO11ueDft7QU6P/TAzNqyywqZiy76kzdw
1qU77vhXlGtZQCuxbfgvpLin1ivhOaR/6gfDmsfUlSne5kp+uUrgoRhhEc/krOci
fGSFcutPD/4pziVea31UcngwJRo/s9AfHkjviVMpGJIQo3vtejq53UQu8yWWc/uW
G5z+pxOuK3QdTjtzrmOiCGj1bWZ+I33K+fBbZcf7C+o4HV9KaexW1db3wBtwUFWO
7TFezkBDaKbgxgaryh1+RcetQP7cdN2Chcy0EWf10S8/N8whj2ZyAcIuIoT8wM7i
xWmnQRiI2l2+7AhQfqGFUk+PEYRvRyRtjF8X9buYVBh/9rFrScH6aK+gicCcU1gJ
Zpc51QEDDSfAYF6wV8pWnILKcXqdDZhEh1hnTUitUL9mlZEaenGjSPCtcGVg3s9l
CuXJij89s74IyfCdjJsmy9K5GxQyhUJb0nyy5wOpGPGmDueTiP32JuXOxNeEp+gY
3rxygMNzAmL2QjLajLpE6kj+mEMBYSTWyni1W7c5i0PnOsi22yXV+2W+XaeC+9Pm
424uM8e2Y0+C9lI6AqDziL58fP2V6FxJTpbzBxANqKwSh5N0we1Cfw/ZPC0LyebZ
KbmPcNoSoqaOYXo3h0LFsDL2aA0PTJroAV1p/xxVoxDeGkX+hJXh+6ErVhEOb+gv
+LiUabBFtHTa7yPVtQWLFWf4njFQIytt8iDTpFDfK1OApe25xilrTRZT147KtKwL
5tDl33hFKbspcqALa7ozwE1Tr8/yrddainGQSIfx4CAfk8P5aqi19LkCDQRkIotT
ARAAxjaJMoCvKYNWaJ5m9K9KsfoKss8CXiy3SEhbcqh/Yy4osiODjoWjS+lsz58G
uyPphLXjdhIn9DWPnYKKoV7sB1y2RTCLsZ9jJaqHBL3e+gL78zS8hNHcq3HxWEwb
SYRHr8pBKWL7/X4m+2cuMC/wnK+QWIGB4S03yMZGMbC8GTfuj6tdO4GZYfCGVWHi
gv1ERGaArlqmXk+TkQQmTUpfhdqNBKWllZK56/oUMDNGsRrgEP8TzU4z+YbJK0FJ
7V9dY1j28K8oqLDgA+/aiLv2gpS+qsmowMhxKN/axvF+FCZbGS3+/h4subZMIcbI
xxDHSPqPgA+f0GQHIHsy9gELMQtkXTP5xzZuoDGX+F2LFb68wHd3jCNpfFVEfTP2
8CcyLbjciyY8wod6WLa7q0VNDlSGEXH5thaNnidCwynNCF+NaFQMVf027jThp6S/
nWtUZFPCMGx9jj8mbopkSsfF7E9fErRtCI8dAnmcE/ottvueAN7Q3XAUlsilLM8M
HhkSZobaUBynewcEIpHSY4vOfRWnhQI60WGfD7x7dMuIakao9euSg9g/u7WMCV6U
ShElJdYdpZA/H/jMFb17zuH9yp5cGNNMeUP2WvEWtUHA36nGI4+oE3SszOSRF4+E
YAozF6Hh1MrC/hXe3NShoDq68hG5e1SsndLZ1B9Gt/nAqiEAEQEAAYkCPAQYAQoA
JhYhBACugXwkoQwlQEYanB183gI020WNBQJkIotTAhsMBQkFo5qAAAoJEB183gI0
20WNldAP/17KozqrwUA8mlYU3zpc/P0HdBtL/rn5Fx87MZ2E8RPuVMyNg6I4KoU5
Kmh0vy6cL8vG7fqYXM1ieiy9wTMxiGaWDL7QZY3LBXQ2mFfGd2rAAhwloTEcPn6i
Ro/X0C5aBGGy5iACOfpRA774XsNQG6cgBY/Jq0/D2Jom78Vv0k3H0oD1L5BrRO/H
5L9TriBW9el4F/USpaQDjR/KiSfsBr6HLpht1OQJ+21kUbGgvse7DdTtZeK4q3wR
1v4OV9EX1m09WUL+7Cra1OFSc9bZ0fcVY98zGXm8LTtipiBc//ZrDjMutRdOj4ct
RHDiKHBEYFxHGeAj87Xwc9q6ph2MspjXS4qHVJRWtyx5DQcrf6gY3bH73SByhOXj
SVDpfeDvO4BpQ+8q4d9AjcGa6NqGTXR8P5Y8jnZG68buwGstBbz2J2fHBs0SrBMg
3T6HSB3z4gD/WkPE8bT/9oMpSLD0mdHQAYJviOa39rRGII6Jzkd1EL9tVDU9QenX
hVx2v3ZWL8Iq1Bm8zwiDAGsiHcHmxY8sQmfuwWQdYXhxXBcG0kBNKz+158uyFr9u
Skp8e1INBDShReAQuQ5PAGBIrZ5aElPaK/2puNeAmd3cholvpeu0CuEaxpLi0Tq3
y/xhPPFMdZ4llt90sotKeYnHmvsYUJe2on8afl9bwotz8On484vVuQINBGQii2cB
EAC/YnmAiKO05oN129GedPTDrvJk6PbXHUYb5UtNisAwLVXeKSpo5OWyckDZ1IoV
9xvOdH+TWJvgX5x7gPZoD9COYHfMQRZeysZ89wCocH55PsAwmvjM87rAKLbkyZl8
sehgsri09amBlMoSeTVN49U5lt9EZWVKZeACtDk9D86OX7r154NM7uSxvQVeydth
Bj/Rdh15RUfsKTZYxmzZ/1x3FnHzOLTDkX5QmBIBlthVN2IaT8U8pfKpoStOlBza
j1MdrdhtkDH4YAFi2X9KlkoP3Z2fYCefVcLJw+k3D8nwPyXmGuJhG0oHsPyesQGz
FSnIM6ZWhqh76yS1EQxK125NKu9FeHJBAEOg0RISpe/LhNNLjUQ0dC9gRx9l+p46
hIMUXwMPNENMFihNqP4tRLvF/0KI1oj7634rei+dZKWuja6yk/QaOcztmcyS2Aca
n3llExISb3beNncQHaAYg8ADHR+852RZQ81yUFUF7yrxclSJmF5zO4fJAedacClA
FuGnQvIQZv01YULOtDn3fTq8eY912VZx+SxpO2IwTObYCdnSBHigQBp13UTcg5WV
HhmfwJKI328GaPkBa0eIqxc5gR7X6PmrLvxlCbrMC9IHjlwd203eKMhqRoIJYXEv
Ebsx02Zceh4tMH9RDH2XNpHLt604rCLJTReRORXsAH/zBQARAQABiQI8BBgBCgAm
FiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQii2cCGyAFCQWjmoAACgkQHXzeAjTb
RY1TiA/+N4dIfoHMsEZ53DwrbRqDXlzTfkfqWd99tE72Lecsns2ih4/4wHOgzV7z
SV6002SZK/PHRYikmxSSxmoNbx5yNMp9vI8j031YShAJd6QU+NVjY3oB4ivF6wRa
vP2OYO0vamwTw54e5quKmg+ZntFhWY55YNWCqqcYZdHI4GtvbhsCEuS/ceZ1XoXY
xbtaNJHAn5yG+/VLNu2fiAiu+e4+xEQ2UjV8rC60MU9tZafMbALlHUXGDY0tUCzv
/BF3GDQk3dxN+fEBnassVXgZm30dOB2XqVIF5g+l6iufmT9WcDTbnXyYbEBRVTJ1
DpTbmtwUpuYdSX41NPPojK3XcesP+PR8x7tWU7AEWzV827I4sx54HjJVMj2TWSGB
X+xDgthbqqtm1VZPNL2yHJzxHgIPqo6iQLaAGphR/L+ULFeJnFNjgOatt7vcG7pr
ZVLK1Kq+gc0X+73grlm89XC5R3mNFNOUMWXJ7YniqzCzsTiOwyGP40pvY1vP8v61
509UcUjfXyIhls6vAl1jo/BA0jLuUODQ9P4QqWm4wy7MzMfWBmWKsaubCiiHuala
rXFaJVtIgM/bl089klXVzxD3Beo0PCnuU/6qBgkM6ulS+/wxqU7chW6ClHwdY8U0
NU3X/uocFtQrI3WLcE0vMc0IHa8VjDb8r6ztC9Vsti6iPMdScOM=
=IfFs
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,293 +0,0 @@
{
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
"db_issues_reported": "issue(s) reported",
"db_reopen_failed_after_rekey": "Re-open failed after rekey",
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
"database_compacted_successfully": "Database compacted successfully!",
"encryption": "Encryption",
"remember_key": "Remember key",
"change_encryption_key": "Change encryption key",
"enter_a_new_encryption_key": "Enter a new encryption key",
"reenter_the_new_key": "Re-enter the new key",
"key_mismatch": "Key mismatch",
"key_mismatch_explanation": "The two entries did not match.",
"empty_key": "Empty key",
"empty_key_explanation": "The key cannot be empty.",
"key_changed": "Key changed",
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error",
"success": "Success",
"close": "&Close",
"find": "Find",
"file": "File",
"locale": "Language",
"locale_restart": "Please restart the application to load the new language.",
"settings": "Settings",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
"never": "Never",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
"next": "Next",
"next_day": "Next day",
"today": "Today",
"show": "Show",
"history": "History",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
"export_failed": "Export failed",
"backup": "Backup",
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
"documentation": "Documentation",
"couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug",
"version": "Version",
"update": "Update",
"check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?",
"downloading": "Downloading",
"download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate",
"current": "current",
"selected": "selected",
"find_on_page": "Find on page",
"find_next": "Find next",
"find_previous": "Find previous",
"find_bar_type_to_search": "Type to search",
"find_bar_match_case": "Match case",
"history_dialog_preview": "Preview",
"history_dialog_diff": "Diff",
"history_dialog_revert_to_selected": "&Revert to selected",
"history_dialog_revert_failed": "Revert failed",
"history_dialog_delete": "&Delete revision",
"history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key",
"lock_overlay_locked": "Locked",
"lock_overlay_unlock": "Unlock",
"main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready",
"main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs",
"set_an_encryption_key": "Set an encryption key",
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
"unlock_encrypted_notebook": "Unlock encrypted notebook",
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
"open_in_new_tab": "Open in new tab",
"autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"insert_images": "Insert images",
"images": "Images",
"reopen_failed": "Re-open failed",
"unlock_failed": "Unlock failed",
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
"unencrypted_export": "Unencrypted export",
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
"unrecognised_extension": "Unrecognised extension!",
"backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at",
"appearance": "Appearance",
"security": "Security",
"features": "Features",
"database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle",
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
"font_size": "Font size",
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
"search_for_notes_here": "Search for notes here",
"toolbar_format": "Format",
"toolbar_bold": "Bold",
"toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text",
"toolbar_font_smaller": "Smaller text",
"toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block",
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"color_hex": "Colour",
"date": "Date",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics",
"stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions",
"stats_total_words": "Total words (current versions)",
"stats_unique_tags": "Unique tags",
"stats_page_most_tags": "Page with most tags",
"stats_activity_heatmap": "Activity heatmap",
"stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words",
"stats_metric_revisions": "Revisions",
"stats_no_data": "No statistics available yet.",
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
"bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set reminder prompt",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm",
"activities": "Activities",
"activity": "Activity",
"note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required",
"add_activity": "Add activity",
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
"delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours": "Hours",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log",
"project": "Project",
"project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required",
"project_required_title": "Project required",
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
"rename_activity_label": "Rename activity",
"add_project_title": "Add project",
"add_project_label": "Add a project",
"rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project",
"rename_project_title": "Rename this project",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"time_log_date_label": "Time log date: {date}",
"time_log_for": "Time log for {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
"no_report_message": "Please run a report before exporting.",
"total": "Total",
"export_csv": "Export CSV",
"export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"stop_and_log": "Stop and log",
"once": "once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"set_reminder": "Set reminder",
"edit_reminder": "Edit reminder",
"reminder": "Reminder",
"time": "Time",
"once_today": "Once (today)",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"day": "Day"
}

View file

@ -1,290 +0,0 @@
{
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
"db_issues_reported": "problème(s) signalé(s)",
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
"db_key_incorrect": "La clé est probablement incorrecte",
"db_database_error": "Erreur de base de données",
"database_maintenance": "Maintenance de la base de données",
"database_compact": "Compacter la base de données",
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
"database_compacted_successfully": "Base de données compactée avec succès !",
"encryption": "Chiffrement",
"remember_key": "Se souvenir de la clé",
"change_encryption_key": "Changer la clé de chiffrement",
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
"key_mismatch": "Les clés ne correspondent pas",
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
"empty_key": "La clé est vide",
"empty_key_explanation": "La clé ne peut pas être vide.",
"key_changed": "La clé a été modifiée",
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
"error": "Erreur",
"success": "Succès",
"close": "Fermer",
"find": "Rechercher",
"file": "Fichier",
"locale": "Langue",
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
"settings": "Paramètres",
"theme": "Thème",
"system": "Système",
"light": "Clair",
"dark": "Sombre",
"never": "Jamais",
"close_tab": "Fermer l'onglet",
"previous": "Précédent",
"previous_day": "Jour précédent",
"next": "Suivant",
"next_day": "Jour suivant",
"today": "Aujourd'hui",
"show": "Afficher",
"history": "Historique",
"export_accessible_flag": "E&xporter",
"export_entries": "Exporter les entrées",
"export_complete": "Exportation terminée",
"export_failed": "Échec de l'exportation",
"backup": "Sauvegarder",
"backup_complete": "Sauvegarde terminée",
"backup_failed": "Échec de la sauvegarde",
"quit": "Quitter",
"cancel": "Annuler",
"save": "Enregistrer",
"help": "Aide",
"saved": "Enregistré",
"saved_to": "Enregistré dans",
"documentation": "Documentation",
"couldnt_open": "Impossible d'ouvrir",
"report_a_bug": "Signaler un bug",
"version": "Version",
"update": "Mise à jour",
"check_for_updates": "Rechercher des mises à jour",
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
"download_the_appimage": "Télécharger l'AppImage ?",
"downloading": "Téléchargement en cours",
"download_cancelled": "Téléchargement annulé",
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
"navigate": "Naviguer",
"current": "actuel",
"selected": "sélectionné",
"find_on_page": "Rechercher dans la page",
"find_next": "Rechercher le suivant",
"find_previous": "Rechercher le précédent",
"find_bar_type_to_search": "Tapez pour rechercher",
"find_bar_match_case": "Respecter la casse",
"history_dialog_preview": "Aperçu",
"history_dialog_diff": "Différences",
"history_dialog_revert_to_selected": "Revenir à la sélection",
"history_dialog_revert_failed": "Échec de la restauration",
"history_dialog_delete": "Supprimer la révision",
"history_dialog_delete_failed": "Impossible de supprimer la révision",
"key_prompt_enter_key": "Saisir la clé",
"lock_overlay_locked": "Verrouillé",
"lock_overlay_unlock": "Déverrouiller",
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
"main_window_ready": "Prêt",
"main_window_save_a_version": "Enregistrer une version",
"main_window_settings_accessible_flag": "&Paramètres",
"set_an_encryption_key": "Définir une clé de chiffrement",
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"autosave": "enregistrement automatique",
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
"insert_images": "Insérer des images",
"images": "Images",
"reopen_failed": "Échec de la réouverture",
"unlock_failed": "Échec du déverrouillage",
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
"unencrypted_export": "Export non chiffré",
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
"unrecognised_extension": "Extension non reconnue !",
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
"appearance": "Apparence",
"security": "Sécurité",
"features": "Fonctionnalités",
"database": "Base de données",
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
"font_size": "Taille de police",
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
"search_for_notes_here": "Recherchez des notes ici",
"toolbar_format": "Format",
"toolbar_bold": "Gras",
"toolbar_italic": "Italique",
"toolbar_strikethrough": "Barré",
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
"toolbar_font_smaller": "Texte plus petit",
"toolbar_font_larger": "Texte plus grand",
"toolbar_bulleted_list": "Liste à puces",
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Étiquettes",
"tag": "Étiquette",
"manage_tags": "Gérer les étiquettes",
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
"tag_browser_title": "Navigateur d'étiquettes",
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
"color_hex": "Couleur",
"date": "Date",
"add_a_tag": "Ajouter une étiquette",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
"statistics": "Statistiques",
"main_window_statistics_accessible_flag": "Stat&istiques",
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
"stats_total_revisions": "Nombre total de révisions",
"stats_page_most_revisions": "Page avec le plus de révisions",
"stats_total_words": "Nombre total de mots (versions actuelles)",
"stats_unique_tags": "Étiquettes uniques",
"stats_page_most_tags": "Page avec le plus d'étiquettes",
"stats_activity_heatmap": "Carte de chaleur d'activité",
"stats_heatmap_metric": "Colorer selon",
"stats_metric_words": "Mots",
"stats_metric_revisions": "Révisions",
"stats_no_data": "Aucune statistique disponible pour le moment.",
"select_notebook": "Sélectionner un bouquin",
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
"send": "Envoyer",
"reminder": "Rappel",
"set_reminder": "Définir le rappel",
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
"invalid_time_title": "Heure invalide",
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
"dismiss": "Ignorer",
"toolbar_alarm": "Régler l'alarme de rappel",
"activities": "Activités",
"activity": "Activité",
"note": "Note",
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
"activity_rename_error_title": "Problème lors du renommage de l'activité",
"activity_required_message": "Un nom d'activité est requis",
"activity_required_title": "Nom d'activité requis",
"add_activity": "Ajouter une activité",
"add_project": "Ajouter un projet",
"add_time_entry": "Ajouter une entrée de temps",
"time_period": "Période",
"by_day": "par jour",
"by_month": "par mois",
"by_week": "par semaine",
"date_range": "Plage de dates",
"delete_activity": "Supprimer l'activité",
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
"delete_project": "Supprimer le projet",
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
"delete_time_entry": "Supprimer l'entrée de temps",
"group_by": "Grouper par",
"hours": "Heures",
"invalid_activity_message": "L'activité est invalide",
"invalid_activity_title": "Activité invalide",
"invalid_project_message": "Le projet est invalide",
"invalid_project_title": "Projet invalide",
"manage_activities": "Gérer les activités",
"manage_projects": "Gérer les projets",
"manage_projects_activities": "Gérer les activités du projet",
"open_time_log": "Ouvrir le journal de temps",
"project": "Projet",
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
"project_delete_error_title": "Problème lors de la suppression du projet",
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
"project_rename_error_title": "Problème lors du renommage du projet",
"project_required_message": "Un projet est requis",
"project_required_title": "Projet requis",
"projects": "Projets",
"rename_activity": "Renommer l'activité",
"rename_project": "Renommer le projet",
"run_report": "Exécuter le rapport",
"add_activity_title": "Ajouter une activité",
"add_activity_label": "Ajouter une activité",
"rename_activity_label": "Renommer l'activité",
"add_project_title": "Ajouter un projet",
"add_project_label": "Ajouter un projet",
"rename_activity_title": "Renommer cette activité",
"rename_project_label": "Renommer le projet",
"rename_project_title": "Renommer ce projet",
"select_activity_message": "Sélectionner une activité",
"select_activity_title": "Sélectionner une activité",
"select_project_message": "Sélectionner un projet",
"select_project_title": "Sélectionner un projet",
"time_log": "Journal de temps",
"time_log_collapsed_hint": "Journal de temps",
"time_log_date_label": "Date du journal de temps : {date}",
"time_log_for": "Journal de temps pour {date}",
"time_log_no_date": "Journal de temps",
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
"time_log_report": "Rapport de temps",
"time_log_report_title": "Journal de temps pour {project}",
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
"update_time_entry": "Mettre à jour l'entrée de temps",
"time_report_total": "Total : {hours:.2f} heures",
"no_report_title": "Aucun rapport",
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
"total": "Total",
"export_csv": "Exporter en CSV",
"export_csv_error_title": "Échec de l'exportation",
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
"export_pdf": "Exporter en PDF",
"export_pdf_error_title": "Échec de l'exportation PDF",
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
"enable_tags_feature": "Activer les étiquettes",
"enable_time_log_feature": "Activer le journal de temps",
"enable_reminders_feature": "Activer les rappels",
"pomodoro_time_log_default_text": "Session de concentration",
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
"set_code_language": "Définir le langage du code",
"cut": "Couper",
"copy": "Copier",
"paste": "Coller",
"start": "Démarrer",
"pause": "Pause",
"resume": "Reprendre",
"stop_and_log": "Arrêter et enregistrer",
"once": "une fois",
"daily": "quotidien",
"weekdays": "jours de semaine",
"weekly": "hebdomadaire",
"edit_reminder": "Modifier le rappel",
"time": "Heure",
"once_today": "Une fois (aujourd'hui)",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",
"repeat": "Répéter",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"day": "Jour"
}

View file

@ -1,161 +0,0 @@
{
"db_sqlcipher_integrity_check_failed": "Controllo di integrità SQLCipher fallito",
"db_issues_reported": "problema/i segnalato/i",
"db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave",
"db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata",
"db_database_error": "Errore del database",
"db_key_incorrect": "La chiave è probabilmente errata",
"database_maintenance": "Manutenzione del database",
"database_compact": "Compatta il database",
"database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.",
"database_compacted_successfully": "Database compattato con successo!",
"encryption": "Crittografia",
"remember_key": "Ricorda la chiave",
"change_encryption_key": "Cambia chiave di crittografia",
"enter_a_new_encryption_key": "Inserisci una nuova chiave di crittografia",
"reenter_the_new_key": "Reinserisci la nuova chiave",
"key_mismatch": "Le chiavi non corrispondono",
"key_mismatch_explanation": "Le due chiavi inserite non corrispondono.",
"empty_key": "Chiave vuota",
"empty_key_explanation": "La chiave non può essere vuota.",
"key_changed": "Chiave cambiata",
"key_changed_explanation": "Il blocco note è stato criptato nuovamente con la nuova chiave!",
"error": "Errore",
"success": "Successo",
"close": "Chiudi",
"find": "Trova",
"file": "File",
"locale": "Lingua",
"locale_restart": "Per favore riavvia l'applicazione per caricare la nuova lingua.",
"settings": "Impostazioni",
"theme": "Tema",
"system": "Sistema",
"light": "Chiaro",
"dark": "Scuro",
"never": "Mai",
"previous": "Precedente",
"previous_day": "Giorno precedente",
"next": "Successivo",
"next_day": "Giorno successivo",
"today": "Oggi",
"show": "Mostra",
"history": "Cronologia",
"export_accessible_flag": "&Esporta",
"export_entries": "Esporta voci",
"export_complete": "Esportazione completata",
"export_failed": "Esportazione fallita",
"backup": "Backup",
"backup_complete": "Backup completato",
"backup_failed": "Backup fallito",
"quit": "Esci",
"help": "Aiuto",
"saved": "Salvato",
"saved_to": "Salvato in",
"documentation": "Documentazione",
"couldnt_open": "Impossibile aprire",
"report_a_bug": "Segnala un bug",
"version": "Versione",
"navigate": "Naviga",
"current": "corrente",
"selected": "selezionato",
"find_on_page": "Trova nella pagina",
"find_next": "Trova successivo",
"find_previous": "Trova precedente",
"find_bar_type_to_search": "Digita per cercare",
"find_bar_match_case": "Distingui maiuscole/minuscole",
"history_dialog_preview": "Anteprima",
"history_dialog_diff": "Differenze",
"history_dialog_revert_to_selected": "Ripristina alla versione selezionata",
"history_dialog_revert_failed": "Ripristino fallito",
"key_prompt_enter_key": "Inserisci la chiave",
"lock_overlay_locked": "Bloccato",
"lock_overlay_unlock": "Sblocca",
"main_window_ready": "Pronto",
"main_window_save_a_version": "Salva versione",
"main_window_settings_accessible_flag": "Impo&stazioni",
"set_an_encryption_key": "Imposta una chiave di crittografia",
"set_an_encryption_key_explanation": "Bouquin cripta i tuoi dati.\n\nCrea una passphrase sicura per criptare il blocco note.\n\nPuoi sempre cambiarla in seguito!",
"unlock_encrypted_notebook": "Sblocca il blocco note criptato",
"unlock_encrypted_notebook_explanation": "Inserisci la chiave per sbloccare il blocco note",
"open_in_new_tab": "Apri in una nuova scheda",
"autosave": "salvataggio automatico",
"unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo",
"move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio",
"insert_images": "Inserisci immagini",
"images": "Immagini",
"reopen_failed": "Riapertura fallita",
"unlock_failed": "Sblocco fallito",
"could_not_unlock_database_at_new_path": "Impossibile sbloccare il database nel nuovo percorso.",
"unencrypted_export": "Esportazione non criptata",
"unencrypted_export_warning": "L'esportazione del database sarà non criptata!\nVuoi davvero continuare?\nSe desideri un backup criptato, scegli Backup invece di Esporta.",
"unrecognised_extension": "Estensione non riconosciuta!",
"backup_encrypted_notebook": "Backup del blocco note criptato",
"enter_a_name_for_this_version": "Inserisci un nome per questa versione",
"new_version_i_saved_at": "Nuova versione salvata il",
"save_key_warning": "Se non vuoi che ti venga richiesta la chiave di crittografia, seleziona questa opzione per ricordarla.\nATTENZIONE: la chiave viene salvata sul disco e potrebbe essere recuperabile se il disco fosse compromesso.",
"lock_screen_when_idle": "Blocca lo schermo quando inattivo",
"autolock_explanation": "Bouquin bloccherà automaticamente il blocco note dopo questo intervallo di tempo, dopodiché sarà necessario reinserire la chiave per sbloccarlo.\nImposta a 0 (mai) per non bloccarlo mai.",
"search_for_notes_here": "Cerca note qui",
"toolbar_format": "Formato",
"toolbar_bold": "Grassetto",
"toolbar_italic": "Corsivo",
"toolbar_strikethrough": "Barrato",
"toolbar_normal_paragraph_text": "Testo normale",
"toolbar_bulleted_list": "Elenco puntato",
"toolbar_numbered_list": "Elenco numerato",
"toolbar_code_block": "Blocco di codice",
"toolbar_heading": "Titolo",
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
"tags": "Tag",
"manage_tags": "Gestisci tag",
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"color_hex": "Colore",
"date": "Data",
"add_a_tag": "Aggiungi un tag",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.",
"tag_already_exists_with_that_name": "Esiste già un tag con questo nome",
"cancel": "Annulla",
"select_notebook": "Seleziona blocco note",
"save": "Salva",
"history_dialog_delete": "Cancella versione",
"check_for_updates": "Controlla aggiornamenti",
"close": "Chiudi",
"send": "Invia",
"time_log": "Registro Attività",
"time_log_no_entries": "Nessuna Attività",
"close_tab": "Chiudi scheda",
"toolbar_font_smaller": "Rimpicciolisci testo",
"toolbar_font_larger": "Ingrandisci testo",
"toolbar_alarm": "Imposta promemoria",
"statistics": "Statistiche",
"main_window_statistics_accessible_flag": "Stat&istiche",
"main_window_lock_screen_accessibility": "B&locca Schermo",
"font_size": "Dimensione carattere",
"font_size_explanation": "Cambiare questo valore camberà la dimensione di tutto il testo in tutte le schede. Dimensione di titoli e blocchi di codice rimarranno invariati",
"enable_tags_feature": "Abilita Tags",
"enable_time_log_feature": "Abilita Traccuamento del tempo",
"appearance": "Interfaccia",
"features": "Funzionalità",
"security": "Sicurezza",
"bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
"bug_report_placeholder": "Scrivi la tua segnalazione qui",
"update": "Aggiornamento",
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
"cut": "Taglia",
"copy": "Copia",
"paste": "Incolla",
"monday": "Lunedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì",
"thursday": "Giovedì",
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica",
"day": "Giorno"
}

View file

@ -1,14 +1,12 @@
from __future__ import annotations
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from . import strings
from .theme import ThemeManager
class LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager):
def __init__(self, parent: QWidget, on_unlock: callable):
"""
Widget that 'locks' the screen after a configured idle time.
"""
@ -18,16 +16,18 @@ class LockOverlay(QWidget):
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self._styling = False # <-- reentrancy guard
self._last_dark: bool | None = None
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel(strings._("lock_overlay_locked"), self)
msg = QLabel("Locked due to inactivity", self)
msg.setObjectName("lockLabel")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
self._btn = QPushButton("Unlock", self)
self._btn.setObjectName("unlockButton")
self._btn.setShortcut("Ctrl+Shift+U")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
@ -38,9 +38,91 @@ class LockOverlay(QWidget):
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
themes.register_lock_overlay(self)
self._apply_overlay_style()
self.hide()
def _is_dark(self, pal: QPalette) -> bool:
"""
Detect if dark mode is in use.
"""
c = pal.color(QPalette.Window)
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
return luma < 0.5
def _apply_overlay_style(self):
if self._styling:
return
dark = self._is_dark(self.palette())
if dark == self._last_dark:
return
self._styling = True
try:
if dark:
link = self.palette().color(QPalette.Link)
accent_hex = link.name() # e.g. "#FFA500"
r, g, b = link.red(), link.green(), link.blue()
self.setStyleSheet(
f"""
#LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
)
else:
# (light mode unchanged)
self.setStyleSheet(
"""
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
)
self._last_dark = dark
finally:
self._styling = False
def changeEvent(self, ev):
super().changeEvent(ev)
# Only re-style on palette flips (user changed theme)
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style()
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())

View file

@ -1,25 +1,17 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from .settings import APP_NAME, APP_ORG, get_settings
from .main_window import MainWindow
from .theme import Theme, ThemeConfig, ThemeManager
from . import strings
def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
# Icon
BASE_DIR = Path(__file__).resolve().parent
ICON_PATH = BASE_DIR / "icons" / "bouquin.svg"
icon = QIcon(str(ICON_PATH))
app.setWindowIcon(icon)
s = get_settings()
theme_str = s.value("ui/theme", "system")
@ -27,7 +19,6 @@ def main():
themes = ThemeManager(app, cfg)
themes.apply(cfg.theme)
strings.load_strings(s.value("ui/locale", "en"))
win = MainWindow(themes=themes)
win.show()
sys.exit(app.exec())

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,344 +0,0 @@
from __future__ import annotations
import re
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QGuiApplication,
QPalette,
QSyntaxHighlighter,
QTextCharFormat,
QTextDocument,
)
from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type."""
def __init__(
self, document: QTextDocument, theme_manager: ThemeManager, editor=None
):
super().__init__(document)
self.theme_manager = theme_manager
self._editor = editor # Reference to the MarkdownEditor
self._setup_formats()
# Recompute formats whenever the app theme changes
self.theme_manager.themeChanged.connect(self._on_theme_changed)
def _on_theme_changed(self, *_):
self._setup_formats()
self.rehighlight()
def refresh_for_font_change(self) -> None:
"""
Called when the editor's base font changes (zoom / settings).
It rebuilds any formats that depend on the editor font metrics.
"""
self._setup_formats()
self.rehighlight()
def _setup_formats(self):
"""Setup text formats for different markdown elements."""
# Bold: **text** or __text__
self.bold_format = QTextCharFormat()
self.bold_format.setFontWeight(QFont.Weight.Bold)
# Italic: *text* or _text_
self.italic_format = QTextCharFormat()
self.italic_format.setFontItalic(True)
# Allow combination of bold/italic
self.bold_italic_format = QTextCharFormat()
self.bold_italic_format.setFontWeight(QFont.Weight.Bold)
self.bold_italic_format.setFontItalic(True)
# Strikethrough: ~~text~~
self.strike_format = QTextCharFormat()
self.strike_format.setFontStrikeOut(True)
# Inline code: `code`
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self.code_format = QTextCharFormat()
self.code_format.setFont(mono)
self.code_format.setFontFixedPitch(True)
# Code block: ```
self.code_block_format = QTextCharFormat()
self.code_block_format.setFont(mono)
self.code_block_format.setFontFixedPitch(True)
pal = QGuiApplication.palette()
if (
self.theme_manager.current() == Theme.DARK
or self.theme_manager._is_system_dark
):
# In dark mode, use a darker panel-like background
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = QColor( # pragma: no cover
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)
# Headings
self.h1_format = QTextCharFormat()
self.h1_format.setFontPointSize(24.0)
self.h1_format.setFontWeight(QFont.Weight.Bold)
self.h2_format = QTextCharFormat()
self.h2_format.setFontPointSize(18.0)
self.h2_format.setFontWeight(QFont.Weight.Bold)
self.h3_format = QTextCharFormat()
self.h3_format.setFontPointSize(14.0)
self.h3_format.setFontWeight(QFont.Weight.Bold)
# Hyperlinks
self.link_format = QTextCharFormat()
link_color = pal.color(QPalette.Link)
self.link_format.setForeground(link_color)
self.link_format.setFontUnderline(True)
self.link_format.setAnchor(True)
# Checkboxes
self.checkbox_format = QTextCharFormat()
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
# Bullets
self.bullet_format = QTextCharFormat()
# Use Symbols font for checkbox and bullet glyphs if present
if self._editor is not None and hasattr(self._editor, "symbols_font_family"):
base_font = QFont(self._editor.qfont) # copy of editor font
symbols_font = QFont(self._editor.symbols_font_family)
symbols_font.setPointSizeF(base_font.pointSizeF())
base_metrics = QFontMetrics(base_font)
sym_metrics = QFontMetrics(symbols_font)
# If Symbols glyphs are noticeably shorter than the text,
# scale them up so the visual heights roughly match.
if sym_metrics.height() > 0:
ratio = base_metrics.height() / sym_metrics.height()
if ratio > 1.05: # more than ~5% smaller
ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it.
symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio)
self.checkbox_format.setFont(symbols_font)
self.bullet_format.setFont(symbols_font)
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
# Use the editor background color so they blend in
bg = pal.color(QPalette.Base)
hidden = QColor(bg)
hidden.setAlpha(0)
self.syntax_format.setForeground(hidden)
# Make the markers invisible by setting font size to 0.1 points
self.syntax_format.setFontPointSize(0.1)
def _overlay_range(
self, start: int, length: int, overlay_fmt: QTextCharFormat
) -> None:
"""Merge overlay_fmt onto the existing format for each char in [start, start+length)."""
end = start + length
i = start
while i < end:
base = QTextCharFormat(self.format(i)) # current format at this position
base.merge(overlay_fmt) # add only the properties we set
self.setFormat(i, 1, base) # write back one char
i += 1
def highlightBlock(self, text: str):
"""Apply formatting to a block of text based on markdown syntax."""
# Track if we're in a code block (multiline)
prev_state = self.previousBlockState()
in_code_block = prev_state == 1
# Check for code block fences
if text.strip().startswith("```"):
# background for the whole fence line (so block looks continuous)
self.setFormat(0, len(text), self.code_block_format)
# hide the three backticks themselves
idx = text.find("```")
if idx != -1:
self.setFormat(idx, 3, self.syntax_format)
# toggle code-block state and stop; next line picks up state
in_code_block = not in_code_block
self.setCurrentBlockState(1 if in_code_block else 0)
return
if in_code_block:
# inside code: apply block bg and language rules
self.setFormat(0, len(text), self.code_block_format)
# Try to apply language-specific highlighting
if self._editor and hasattr(self._editor, "_code_metadata"):
from .code_highlighter import CodeHighlighter
# Find the opening fence block
prev_block = self.currentBlock().previous()
fence_block_num = None
temp_inside = in_code_block
while prev_block.isValid():
if prev_block.text().strip().startswith("```"):
temp_inside = not temp_inside
if not temp_inside:
fence_block_num = prev_block.blockNumber()
break
prev_block = prev_block.previous()
if fence_block_num is not None:
language = self._editor._code_metadata.get_language(fence_block_num)
if language:
patterns = CodeHighlighter.get_language_patterns(language)
for pattern, syntax_type in patterns:
for match in re.finditer(pattern, text):
start, end = match.span()
fmt = CodeHighlighter.get_format_for_type(
syntax_type, self.code_block_format
)
self.setFormat(start, end - start, fmt)
self.setCurrentBlockState(1)
return
# ---- Normal markdown (outside code)
self.setCurrentBlockState(0)
# If the line is empty and not in a code block, nothing else to do
if not text:
return
# Headings (must be at start of line)
heading_match = re.match(r"^(#{1,3})\s+", text)
if heading_match:
level = len(heading_match.group(1))
marker_len = len(heading_match.group(0))
# Format the # markers
self.setFormat(0, marker_len, self.syntax_format)
# Format the heading text
heading_fmt = (
self.h1_format
if level == 1
else self.h2_format if level == 2 else self.h3_format
)
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
return
# Bold+Italic (*** or ___): do these first and record occupied spans.
# --- Triple emphasis: detect first, hide markers now, but DEFER applying content style
triple_contents: list[tuple[int, int]] = [] # (start, length) for content only
occupied: list[tuple[int, int]] = (
[]
) # full spans including markers, for overlap checks
for m in re.finditer(
r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*|(?<!_)___(.+?)(?<!_)___", text
):
start, end = m.span()
content_start, content_end = start + 3, end - 3
# hide the *** / ___ markers now
self.setFormat(start, 3, self.syntax_format)
self.setFormat(end - 3, 3, self.syntax_format)
# remember the full occupied span and the content span
occupied.append((start, end))
triple_contents.append((content_start, content_end - content_start))
def _overlaps(a, b): # a, b are (start, end)
return not (a[1] <= b[0] or b[1] <= a[0])
# --- Bold (**) or (__): skip if it overlaps any triple
for m in re.finditer(
r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)|(?<!_)__(?!_)(.+?)(?<!_)__(?!_)",
text,
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
continue # pragma: no cover
content_start, content_end = start + 2, end - 2
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.bold_format)
# --- Italic (*) or (_): skip if it overlaps any triple
for m in re.finditer(
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
continue # pragma: no cover
# avoid stealing a single marker that is part of a double
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
continue # pragma: no cover
if end < len(text) and text[end : end + 1] in ("*", "_"):
continue # pragma: no cover
content_start, content_end = start + 1, end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
self.setFormat(
content_start, content_end - content_start, self.italic_format
)
# --- NOW overlay bold+italic for triple contents LAST (so nothing clobbers it)
for cs, length in triple_contents:
self._overlay_range(cs, length, self.bold_italic_format)
# Strikethrough: ~~text~~
for m in re.finditer(r"~~(.+?)~~", text):
start, end = m.span()
content_start, content_end = start + 2, end - 2
# Fade the markers
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
# Merge strikeout with whatever is already applied (bold, italic, both, links, etc.)
self._overlay_range(
content_start, content_end - content_start, self.strike_format
)
# Inline code: `code`
for match in re.finditer(r"`([^`]+)`", text):
start, end = match.span()
content_start = start + 1
content_end = end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.code_format)
# Hyperlinks
url_pattern = re.compile(r"(https?://[^\s<>()]+)")
for m in url_pattern.finditer(text):
start, end = m.span(1)
url = m.group(1)
# Clone link format so we can attach a per-link href
fmt = QTextCharFormat(self.link_format)
fmt.setAnchorHref(url)
# Overlay link attributes on top of whatever formatting is already there
self._overlay_range(start, end - start, fmt)
# Make checkbox glyphs bigger
for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format)

View file

@ -1,149 +0,0 @@
from __future__ import annotations
import math
from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from . import strings
from .db import DBManager
from .time_log import TimeLogDialog
class PomodoroTimer(QDialog):
"""A simple timer dialog for tracking work time on a specific task."""
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
self.setModal(False)
self.setMinimumWidth(300)
self._task_text = task_text
self._elapsed_seconds = 0
self._running = False
layout = QVBoxLayout(self)
# Task label
task_label = QLabel(task_text)
task_label.setWordWrap(True)
layout.addWidget(task_label)
# Timer display
self.time_label = QLabel("00:00:00")
font = self.time_label.font()
font.setPointSize(24)
font.setBold(True)
self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.time_label)
# Control buttons
btn_layout = QHBoxLayout()
self.start_pause_btn = QPushButton(strings._("start"))
self.start_pause_btn.clicked.connect(self._toggle_timer)
btn_layout.addWidget(self.start_pause_btn)
self.stop_btn = QPushButton(strings._("stop_and_log"))
self.stop_btn.clicked.connect(self._stop_and_log)
self.stop_btn.setEnabled(False)
btn_layout.addWidget(self.stop_btn)
layout.addLayout(btn_layout)
# Internal timer (ticks every second)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
@Slot()
def _toggle_timer(self):
"""Start or pause the timer."""
if self._running:
# Pause
self._running = False
self._timer.stop()
self.start_pause_btn.setText(strings._("resume"))
else:
# Start/Resume
self._running = True
self._timer.start(1000) # 1 second
self.start_pause_btn.setText(strings._("pause"))
self.stop_btn.setEnabled(True)
@Slot()
def _tick(self):
"""Update the elapsed time display."""
self._elapsed_seconds += 1
self._update_display()
def _update_display(self):
"""Update the time display label."""
hours = self._elapsed_seconds // 3600
minutes = (self._elapsed_seconds % 3600) // 60
seconds = self._elapsed_seconds % 60
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
@Slot()
def _stop_and_log(self):
"""Stop the timer and emit signal to open time log."""
if self._running:
self._running = False
self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.accept()
class PomodoroManager:
"""Manages Pomodoro timers and integrates with time log."""
def __init__(self, db: DBManager, parent_window):
self._db = db
self._parent = parent_window
self._active_timer: Optional[PomodoroTimer] = None
def start_timer_for_line(self, line_text: str, date_iso: str):
"""Start a new timer for the given line of text."""
# Stop any existing timer
if self._active_timer and self._active_timer.isVisible():
self._active_timer.close()
# Create new timer
self._active_timer = PomodoroTimer(line_text, self._parent)
self._active_timer.timerStopped.connect(
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
)
self._active_timer.show()
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
"""Handle timer stop - open time log dialog with pre-filled data."""
# Convert seconds to decimal hours, rounded up
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
# Ensure minimum of 0.25 hours
if hours < 0.25:
hours = 0.25
# Open time log dialog
dlg = TimeLogDialog(self._db, date_iso, self._parent)
# Pre-fill the hours
dlg.hours_spin.setValue(hours)
# Pre-fill the note with task text
dlg.note.setText(task_text)
# Show the dialog
dlg.exec()

View file

@ -1,639 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QComboBox,
QTimeEdit,
QPushButton,
QFrame,
QWidget,
QToolButton,
QListWidget,
QListWidgetItem,
QStyle,
QSizePolicy,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
)
from . import strings
from .db import DBManager
class ReminderType(Enum):
ONCE = strings._("once")
DAILY = strings._("daily")
WEEKDAYS = strings._("weekdays") # Mon-Fri
WEEKLY = strings._("weekly") # specific day of week
@dataclass
class Reminder:
id: Optional[int]
text: str
time_str: str # HH:MM
reminder_type: ReminderType
weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
active: bool = True
date_iso: Optional[str] = None # For ONCE type
class ReminderDialog(QDialog):
"""Dialog for creating/editing reminders with recurrence support."""
def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
super().__init__(parent)
self._db = db
self._reminder = reminder
self.setWindowTitle(
strings._("set_reminder") if not reminder else strings._("edit_reminder")
)
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
form = QFormLayout()
# Reminder text
self.text_edit = QLineEdit()
if reminder:
self.text_edit.setText(reminder.text)
form.addRow("&" + strings._("reminder") + ":", self.text_edit)
# Time
self.time_edit = QTimeEdit()
self.time_edit.setDisplayFormat("HH:mm")
if reminder:
parts = reminder.time_str.split(":")
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
else:
self.time_edit.setTime(QTime.currentTime())
form.addRow("&" + strings._("time") + ":", self.time_edit)
# Recurrence type
self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
if reminder:
for i in range(self.type_combo.count()):
if self.type_combo.itemData(i) == reminder.reminder_type:
self.type_combo.setCurrentIndex(i)
break
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
form.addRow("&" + strings._("repeat") + ":", self.type_combo)
# Weekday selector (for weekly reminders)
self.weekday_combo = QComboBox()
days = [
strings._("monday"),
strings._("tuesday"),
strings._("wednesday"),
strings._("thursday"),
strings._("friday"),
strings._("saturday"),
strings._("sunday"),
]
for i, day in enumerate(days):
self.weekday_combo.addItem(day, i)
if reminder and reminder.weekday is not None:
self.weekday_combo.setCurrentIndex(reminder.weekday)
else:
self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
form.addRow("&" + strings._("day") + ":", self.weekday_combo)
layout.addLayout(form)
# Buttons
btn_layout = QHBoxLayout()
btn_layout.addStretch()
save_btn = QPushButton("&" + strings._("save"))
save_btn.clicked.connect(self.accept)
save_btn.setDefault(True)
btn_layout.addWidget(save_btn)
cancel_btn = QPushButton("&" + strings._("cancel"))
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
self._on_type_changed()
def _on_type_changed(self):
"""Show/hide weekday selector based on reminder type."""
reminder_type = self.type_combo.currentData()
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
def get_reminder(self) -> Reminder:
"""Get the configured reminder."""
reminder_type = self.type_combo.currentData()
time_obj = self.time_edit.time()
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
weekday = None
if reminder_type == ReminderType.WEEKLY:
weekday = self.weekday_combo.currentData()
date_iso = None
if reminder_type == ReminderType.ONCE:
# Right now this just means "today at the chosen time".
date_iso = QDate.currentDate().toString("yyyy-MM-dd")
return Reminder(
id=self._reminder.id if self._reminder else None,
text=self.text_edit.text(),
time_str=time_str,
reminder_type=reminder_type,
weekday=weekday,
active=self._reminder.active if self._reminder else True,
date_iso=date_iso,
)
class UpcomingRemindersWidget(QFrame):
"""Collapsible widget showing upcoming reminders for today and next 7 days."""
reminderTriggered = Signal(str) # Emits reminder text
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header with toggle button
self.toggle_btn = QToolButton()
self.toggle_btn.setText("Upcoming Reminders")
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.add_btn = QToolButton()
self.add_btn.setText("")
self.add_btn.setToolTip("Add Reminder")
self.add_btn.setAutoRaise(True)
self.add_btn.clicked.connect(self._add_reminder)
self.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip("Manage All Reminders")
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._manage_reminders)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch()
header.addWidget(self.add_btn)
header.addWidget(self.manage_btn)
# Body with reminder list
self.body = QWidget()
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 4, 0, 0)
body_layout.setSpacing(2)
self.reminder_list = QListWidget()
self.reminder_list.setMaximumHeight(200)
self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.reminder_list.customContextMenuRequested.connect(
self._show_reminder_context_menu
)
body_layout.addWidget(self.reminder_list)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# Timer to check and fire reminders
# Start by syncing to the next minute boundary
self._check_timer = QTimer(self)
self._check_timer.timeout.connect(self._check_reminders)
# Calculate milliseconds until next minute (HH:MM:00)
now = QDateTime.currentDateTime()
current_second = now.time().second()
current_msec = now.time().msec()
# Milliseconds until next minute
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
def __del__(self):
"""Cleanup timers when widget is destroyed."""
try:
if hasattr(self, "_check_timer") and self._check_timer:
self._check_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
self.refresh()
def refresh(self):
"""Reload and display upcoming reminders."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
self.reminder_list.clear()
reminders = self._db.get_all_reminders()
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Get reminders for the next 7 days
upcoming = []
for i in range(8): # Today + 7 days
check_date = today.addDays(i)
for reminder in reminders:
if not reminder.active:
continue
if self._should_fire_on_date(reminder, check_date):
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
dt = QDateTime(check_date, QTime(hour, minute))
# Skip past reminders
if dt < now:
continue
upcoming.append((dt, reminder))
# Sort by datetime
upcoming.sort(key=lambda x: x[0])
# Display
for dt, reminder in upcoming[:20]: # Show max 20
date_str = dt.date().toString("ddd MMM d")
time_str = dt.time().toString("HH:mm")
item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
item.setData(Qt.UserRole, reminder)
self.reminder_list.addItem(item)
if not upcoming:
item = QListWidgetItem("No upcoming reminders")
item.setFlags(Qt.NoItemFlags)
self.reminder_list.addItem(item)
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
"""Check if a reminder should fire on a given date."""
if reminder.reminder_type == ReminderType.ONCE:
if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso
return False
elif reminder.reminder_type == ReminderType.DAILY:
return True
elif reminder.reminder_type == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5
elif reminder.reminder_type == ReminderType.WEEKLY:
# Qt: Monday=1, reminder: Monday=0
return date.dayOfWeek() - 1 == reminder.weekday
return False
def _check_reminders(self):
"""Check if any reminders should fire now."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
reminders = self._db.get_all_reminders()
for reminder in reminders:
if not reminder.active:
continue
if not self._should_fire_on_date(reminder, today):
continue
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
seconds_diff = current_minute.secsTo(target)
if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {}
reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time
if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
# For ONCE reminders, deactivate after firing
if reminder.reminder_type == ReminderType.ONCE:
self._db.update_reminder_active(reminder.id, False)
self.refresh() # Refresh the list to show deactivated reminder
@Slot()
def _add_reminder(self):
"""Open dialog to add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self.refresh()
@Slot(QListWidgetItem)
def _edit_reminder(self, item: QListWidgetItem):
"""Edit an existing reminder."""
reminder = item.data(Qt.UserRole)
if not reminder:
return
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self.refresh()
@Slot()
def _show_reminder_context_menu(self, pos):
"""Show context menu for reminder list item(s)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
from PySide6.QtWidgets import QMenu
from PySide6.QtGui import QAction
menu = QMenu(self)
# Only show Edit if single item selected
if len(selected_items) == 1:
reminder = selected_items[0].data(Qt.UserRole)
if reminder:
edit_action = QAction("Edit", self)
edit_action.triggered.connect(
lambda: self._edit_reminder(selected_items[0])
)
menu.addAction(edit_action)
# Delete option for any selection
if len(selected_items) == 1:
delete_text = "Delete"
else:
delete_text = f"Delete {len(selected_items)} Reminders"
delete_action = QAction(delete_text, self)
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
menu.addAction(delete_action)
menu.exec(self.reminder_list.mapToGlobal(pos))
def _delete_selected_reminders(self):
"""Delete all selected reminders (handling duplicates)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
# Collect unique reminder IDs
unique_reminders = {}
for item in selected_items:
reminder = item.data(Qt.UserRole)
if reminder and reminder.id not in unique_reminders:
unique_reminders[reminder.id] = reminder
if not unique_reminders:
return
# Confirmation message
if len(unique_reminders) == 1:
reminder = list(unique_reminders.values())[0]
msg = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
else:
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
reply = QMessageBox.question(
self,
"Delete Reminders",
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
for reminder_id in unique_reminders:
self._db.delete_reminder(reminder_id)
self.refresh()
def _delete_reminder(self, reminder):
"""Delete a single reminder after confirmation."""
msg = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
reply = QMessageBox.question(
self,
"Delete Reminder",
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self.refresh()
@Slot()
def _manage_reminders(self):
"""Open dialog to manage all reminders."""
dlg = ManageRemindersDialog(self._db, self)
dlg.exec()
self.refresh()
class ManageRemindersDialog(QDialog):
"""Dialog for managing all reminders."""
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setWindowTitle("Manage Reminders")
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["Text", "Time", "Type", "Active", "Actions"]
)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
layout.addWidget(self.table)
# Buttons
btn_layout = QHBoxLayout()
add_btn = QPushButton("Add Reminder")
add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn)
btn_layout.addStretch()
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
self._load_reminders()
def _load_reminders(self):
"""Load all reminders into the table."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
reminders = self._db.get_all_reminders()
self.table.setRowCount(len(reminders))
for row, reminder in enumerate(reminders):
# Text
text_item = QTableWidgetItem(reminder.text)
text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item)
# Time
time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item)
# Type
type_str = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly",
}.get(reminder.reminder_type, "Unknown")
if (
reminder.reminder_type == ReminderType.WEEKLY
and reminder.weekday is not None
):
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
type_str += f" ({days[reminder.weekday]})"
type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item)
# Actions
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(2, 2, 2, 2)
edit_btn = QPushButton("Edit")
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn)
delete_btn = QPushButton("Delete")
delete_btn.clicked.connect(
lambda checked, r=reminder: self._delete_reminder(r)
)
actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget)
def _add_reminder(self):
"""Add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self._load_reminders()
def _edit_reminder(self, reminder):
"""Edit an existing reminder."""
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self._load_reminders()
def _delete_reminder(self, reminder):
"""Delete a reminder."""
reply = QMessageBox.question(
self,
"Delete Reminder",
f"Delete reminder '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self._load_reminders()

View file

@ -2,7 +2,6 @@ from __future__ import annotations
import datetime
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
@ -11,36 +10,25 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
)
from . import strings
class SaveDialog(QDialog):
def __init__(
self,
parent=None,
title: str = "Enter a name for this version",
message: str = "Enter a name for this version?",
):
"""
Used for explicitly saving a new version of a page.
"""
super().__init__(parent)
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
self.setWindowTitle(title)
v = QVBoxLayout(self)
v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
v.addWidget(QLabel(message))
self.note = QLineEdit()
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
text = strings._("new_version_i_saved_at") + f" {now}"
self.note.setText(text)
self.note.setText(f"New version I saved at {now}")
v.addWidget(self.note)
# make dialog wide enough for the line edit text
fm = QFontMetrics(self.note.font())
text_width = fm.horizontalAdvance(text) + 20
self.note.setMinimumWidth(text_width)
self.adjustSize()
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)

View file

@ -16,8 +16,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from . import strings
Row = Tuple[str, str]
@ -32,7 +30,7 @@ class Search(QWidget):
self._db = db
self.search = QLineEdit()
self.search.setPlaceholderText(strings._("search_for_notes_here"))
self.search.setPlaceholderText("Search for notes here")
self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.search.textChanged.connect(self._search)
@ -68,7 +66,10 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights
return
rows: Iterable[Row] = self._db.search_entries(q)
try:
rows: Iterable[Row] = self._db.search_entries(q)
except Exception:
rows = []
self._populate_results(q, rows)
@ -85,7 +86,10 @@ class Search(QWidget):
for date_str, content in rows:
# Build an HTML fragment around the match and whether to show ellipses
frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
frag_html, left_ell, right_ell = self._make_html_snippet(
content, query, radius=30, maxlen=90
)
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
container = QWidget()
outer = QVBoxLayout(container)
@ -107,6 +111,11 @@ class Search(QWidget):
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(4)
if left_ell:
left = QLabel("")
left.setStyleSheet("color:#888;")
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
preview = QLabel()
preview.setTextFormat(Qt.TextFormat.RichText)
preview.setWordWrap(True)
@ -118,6 +127,11 @@ class Search(QWidget):
)
h.addWidget(preview, 1)
if right_ell:
right = QLabel("")
right.setStyleSheet("color:#888;")
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
outer.addWidget(row)
line = QFrame()
@ -134,7 +148,9 @@ class Search(QWidget):
self.results.setItemWidget(item, container)
# --- Snippet/highlight helpers -----------------------------------------
def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180):
def _make_html_snippet(
self, markdown_src: str, query: str, *, radius=60, maxlen=180
):
# For markdown, we can work directly with the text
# Strip markdown formatting for display
plain = self._strip_markdown(markdown_src)
@ -179,7 +195,7 @@ class Search(QWidget):
lambda m: f"<b>{m.group(0)}</b>", snippet_html
)
return snippet_html
return snippet_html, start > 0, end < L
def _strip_markdown(self, markdown: str) -> str:
"""Strip markdown formatting for plain text display."""

View file

@ -9,66 +9,31 @@ APP_ORG = "Bouquin"
APP_NAME = "Bouquin"
def default_db_path() -> Path:
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
return base / "notebook.db"
def get_settings() -> QSettings:
return QSettings(APP_ORG, APP_NAME)
def _default_db_location() -> Path:
"""Where we put the notebook if nothing has been configured yet."""
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
base.mkdir(parents=True, exist_ok=True)
return base / "notebook.db"
def load_db_config() -> DBConfig:
s = get_settings()
# --- DB Path -------------------------------------------------------
# Prefer the new key; fall back to the legacy one.
path_str = s.value("db/default_db", "", type=str)
if not path_str:
legacy = s.value("db/path", "", type=str)
if legacy:
path_str = legacy
# migrate and clean up the old key
s.setValue("db/default_db", legacy)
s.remove("db/path")
path = Path(path_str) if path_str else _default_db_location()
# --- Other settings ------------------------------------------------
path = Path(s.value("db/path", str(default_db_path())))
key = s.value("db/key", "")
idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool)
tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig(
path=path,
key=key,
idle_minutes=idle,
theme=theme,
move_todos=move_todos,
tags=tags,
time_log=time_log,
reminders=reminders,
locale=locale,
font_size=font_size,
path=path, key=key, idle_minutes=idle, theme=theme, move_todos=move_todos
)
def save_db_config(cfg: DBConfig) -> None:
s = get_settings()
s.setValue("db/default_db", str(cfg.path))
s.setValue("db/path", str(cfg.path))
s.setValue("db/key", str(cfg.key))
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -4,21 +4,22 @@ from pathlib import Path
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout,
QVBoxLayout,
QWidget,
QLineEdit,
QPushButton,
QFileDialog,
QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
QMessageBox,
QWidget,
QTabWidget,
)
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
@ -29,64 +30,32 @@ from .settings import load_db_config, save_db_config
from .theme import Theme
from .key_prompt import KeyPrompt
from . import strings
class SettingsDialog(QDialog):
def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("settings"))
self.setWindowTitle("Settings")
self._cfg = DBConfig(path=cfg.path, key="")
self._db = db
self.key = ""
self.current_settings = load_db_config()
self.setMinimumWidth(480)
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(560)
self.setSizeGripEnabled(True)
# --- Tabs ----------------------------------------------------------
tabs = QTabWidget()
tabs.setTabPosition(QTabWidget.North)
tabs.setDocumentMode(True)
tabs.setMovable(False)
current_settings = load_db_config()
tabs.addTab(self._create_appearance_page(cfg), strings._("appearance"))
tabs.addTab(self._create_features_page(), strings._("features"))
tabs.addTab(self._create_security_page(cfg), strings._("security"))
tabs.addTab(self._create_database_page(), strings._("database"))
# --- Buttons -------------------------------------------------------
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
# Root layout
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(8)
root.addWidget(tabs)
root.addWidget(bb, 0, Qt.AlignRight)
# ------------------------------------------------------------------ #
# Pages
# ------------------------------------------------------------------ #
def _create_appearance_page(self, cfg: DBConfig) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
# --- Theme group --------------------------------------------------
theme_group = QGroupBox(strings._("theme"))
# Add theme selection
theme_group = QGroupBox("Theme")
theme_layout = QVBoxLayout(theme_group)
self.theme_system = QRadioButton(strings._("system"))
self.theme_light = QRadioButton(strings._("light"))
self.theme_dark = QRadioButton(strings._("dark"))
self.theme_system = QRadioButton("System")
self.theme_light = QRadioButton("Light")
self.theme_dark = QRadioButton("Dark")
current_theme = self.current_settings.theme
# Load current theme from settings
current_theme = current_settings.theme
if current_theme == Theme.DARK.value:
self.theme_dark.setChecked(True)
elif current_theme == Theme.LIGHT.value:
@ -98,119 +67,63 @@ class SettingsDialog(QDialog):
theme_layout.addWidget(self.theme_light)
theme_layout.addWidget(self.theme_dark)
# font size row
font_row = QHBoxLayout()
self.font_heading = QLabel(strings._("font_size"))
self.font_size = QSpinBox()
self.font_size.setRange(1, 24)
self.font_size.setSingleStep(1)
self.font_size.setAccelerated(True)
self.font_size.setValue(getattr(cfg, "font_size", 11))
font_row.addWidget(self.font_heading)
font_row.addWidget(self.font_size)
font_row.addStretch()
theme_layout.addLayout(font_row)
form.addRow(theme_group)
# explanation
self.font_size_label = QLabel(strings._("font_size_explanation"))
self.font_size_label.setWordWrap(True)
self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
pal = self.font_size_label.palette()
self.font_size_label.setForegroundRole(QPalette.PlaceholderText)
self.font_size_label.setPalette(pal)
font_exp_row = QHBoxLayout()
font_exp_row.setContentsMargins(24, 0, 0, 0)
font_exp_row.addWidget(self.font_size_label)
theme_layout.addLayout(font_exp_row)
layout.addWidget(theme_group)
# --- Locale group -------------------------------------------------
locale_group = QGroupBox(strings._("locale"))
locale_layout = QVBoxLayout(locale_group)
self.locale_combobox = QComboBox()
self.locale_combobox.addItems(strings._AVAILABLE)
self.locale_combobox.setCurrentText(self.current_settings.locale)
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
self.locale_label = QLabel(strings._("locale_restart"))
self.locale_label.setWordWrap(True)
self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
lpal = self.locale_label.palette()
self.locale_label.setForegroundRole(QPalette.PlaceholderText)
self.locale_label.setPalette(lpal)
loc_row = QHBoxLayout()
loc_row.setContentsMargins(24, 0, 0, 0)
loc_row.addWidget(self.locale_label)
locale_layout.addLayout(loc_row)
layout.addWidget(locale_group)
layout.addStretch()
return page
def _create_features_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
features_group = QGroupBox(strings._("features"))
features_layout = QVBoxLayout(features_group)
# Add Behaviour
behaviour_group = QGroupBox("Behaviour")
behaviour_layout = QVBoxLayout(behaviour_group)
self.move_todos = QCheckBox(
strings._("move_unchecked_todos_to_today_on_startup")
"Move yesterday's unchecked TODOs to today on startup"
)
self.move_todos.setChecked(self.current_settings.move_todos)
self.move_todos.setChecked(current_settings.move_todos)
self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos)
self.tags = QCheckBox(strings._("enable_tags_feature"))
self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.tags)
behaviour_layout.addWidget(self.move_todos)
form.addRow(behaviour_group)
self.time_log = QCheckBox(strings._("enable_time_log_feature"))
self.time_log.setChecked(self.current_settings.time_log)
self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log)
self.path_edit = QLineEdit(str(self._cfg.path))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
browse_btn = QPushButton("Browse…")
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
browse_btn.clicked.connect(self._browse)
path_row = QWidget()
h = QHBoxLayout(path_row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(self.path_edit, 1)
h.addWidget(browse_btn, 0)
h.setStretch(0, 1)
h.setStretch(1, 0)
form.addRow("Database path", path_row)
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders)
layout.addWidget(features_group)
layout.addStretch()
return page
def _create_security_page(self, cfg: DBConfig) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
# --- Encryption group ---------------------------------------------
enc_group = QGroupBox(strings._("encryption"))
# Encryption settings
enc_group = QGroupBox("Encryption")
enc = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6)
self.save_key_btn = QCheckBox(strings._("remember_key"))
self.key = self.current_settings.key or ""
# Checkbox to remember key
self.save_key_btn = QCheckBox("Remember key")
self.key = current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor)
self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
self.save_key_label = QLabel(strings._("save_key_warning"))
# Explanation for remembering key
self.save_key_label = QLabel(
"If you don't want to be prompted for your encryption key, check this to remember it. "
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
)
self.save_key_label.setWordWrap(True)
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
pal = self.save_key_label.palette()
self.save_key_label.setForegroundRole(QPalette.PlaceholderText)
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout()
exp_row.setContentsMargins(24, 0, 0, 0)
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
exp_row.addWidget(self.save_key_label)
enc.addLayout(exp_row)
@ -219,77 +132,102 @@ class SettingsDialog(QDialog):
line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line)
self.rekey_btn = QPushButton(strings._("change_encryption_key"))
# Change key button
self.rekey_btn = QPushButton("Change encryption key")
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key)
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
layout.addWidget(enc_group)
form.addRow(enc_group)
# --- Idle lock group ----------------------------------------------
priv_group = QGroupBox(strings._("lock_screen_when_idle"))
# Privacy settings
priv_group = QGroupBox("Lock screen when idle")
priv = QVBoxLayout(priv_group)
priv.setContentsMargins(12, 8, 12, 12)
priv.setSpacing(6)
self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min")
self.idle_spin.setSpecialValueText(strings._("never"))
self.idle_spin.setSpecialValueText("Never")
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
self.idle_spin_label = QLabel(strings._("autolock_explanation"))
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
"Set to 0 (never) to never lock."
)
self.idle_spin_label.setWordWrap(True)
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
spal = self.idle_spin_label.palette()
self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText)
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
spin_row.setContentsMargins(24, 0, 0, 0)
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.addWidget(self.idle_spin_label)
priv.addLayout(spin_row)
layout.addWidget(priv_group)
layout.addStretch()
return page
form.addRow(priv_group)
def _create_database_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
maint_group = QGroupBox(strings._("database_maintenance"))
# Maintenance settings
maint_group = QGroupBox("Database maintenance")
maint = QVBoxLayout(maint_group)
maint.setContentsMargins(12, 8, 12, 12)
maint.setSpacing(6)
self.compact_btn = QPushButton(strings._("database_compact"))
self.compact_btn = QPushButton("Compact database")
self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.compact_btn.clicked.connect(self._compact_btn_clicked)
maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
self.compact_label = QLabel(strings._("database_compact_explanation"))
# Explanation for compating button
self.compact_label = QLabel(
"Compacting runs VACUUM on the database. This can help reduce its size."
)
self.compact_label.setWordWrap(True)
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
cpal = self.compact_label.palette()
self.compact_label.setForegroundRole(QPalette.PlaceholderText)
cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
self.compact_label.setPalette(cpal)
maint_row = QHBoxLayout()
maint_row.setContentsMargins(24, 0, 0, 0)
maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
maint_row.addWidget(self.compact_label)
maint.addLayout(maint_row)
layout.addWidget(maint_group)
layout.addStretch()
return page
form.addRow(maint_group)
# ------------------------------------------------------------------ #
# Save settings
# ------------------------------------------------------------------ #
# Buttons
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
# Root layout (adjust margins/spacing a bit)
v = QVBoxLayout(self)
v.setContentsMargins(12, 12, 12, 12)
v.setSpacing(10)
v.addLayout(form)
v.addWidget(bb, 0, Qt.AlignRight)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
self,
"Choose database file",
self.path_edit.text(),
"DB Files (*.db);;All Files (*)",
)
if p:
self.path_edit.setText(p)
def _save(self):
# Save the selected theme into QSettings
if self.theme_dark.isChecked():
selected_theme = Theme.DARK
elif self.theme_light.isChecked():
@ -300,16 +238,11 @@ class SettingsDialog(QDialog):
key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig(
path=Path(self.current_settings.path),
path=Path(self.path_edit.text()),
key=key_to_save,
idle_minutes=self.idle_spin.value(),
theme=selected_theme.value,
move_todos=self.move_todos.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
)
save_db_config(self._cfg)
@ -317,39 +250,27 @@ class SettingsDialog(QDialog):
self.accept()
def _change_key(self):
p1 = KeyPrompt(
self,
title=strings._("change_encryption_key"),
message=strings._("enter_a_new_encryption_key"),
)
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
if p1.exec() != QDialog.Accepted:
return
new_key = p1.key()
p2 = KeyPrompt(
self,
title=strings._("change_encryption_key"),
message=strings._("reenter_the_new_key"),
)
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
if p2.exec() != QDialog.Accepted:
return
if new_key != p2.key():
QMessageBox.warning(
self, strings._("key_mismatch"), strings._("key_mismatch_explanation")
)
QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
return
if not new_key:
QMessageBox.warning(
self, strings._("empty_key"), strings._("empty_key_explanation")
)
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
return
try:
self.key = new_key
self._db.rekey(new_key)
QMessageBox.information(
self, strings._("key_changed"), strings._("key_changed_explanation")
self, "Key changed", "The notebook was re-encrypted with the new key!"
)
except Exception as e:
QMessageBox.critical(self, strings._("error"), str(e))
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
@Slot(bool)
def _save_key_btn_clicked(self, checked: bool):
@ -357,9 +278,7 @@ class SettingsDialog(QDialog):
if checked:
if not self.key:
p1 = KeyPrompt(
self,
title=strings._("unlock_encrypted_notebook_explanation"),
message=strings._("unlock_encrypted_notebook_explanation"),
self, title="Enter your key", message="Enter the encryption key"
)
if p1.exec() != QDialog.Accepted:
self.save_key_btn.blockSignals(True)
@ -373,10 +292,10 @@ class SettingsDialog(QDialog):
try:
self._db.compact()
QMessageBox.information(
self, strings._("success"), strings._("database_compacted_successfully")
self, "Compact complete", "Database compacted successfully!"
)
except Exception as e:
QMessageBox.critical(self, strings._("error"), str(e))
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
@property
def config(self) -> DBConfig:

View file

@ -1,356 +0,0 @@
from __future__ import annotations
import datetime as _dt
from typing import Dict
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QFormLayout,
QLabel,
QGroupBox,
QHBoxLayout,
QComboBox,
QScrollArea,
QWidget,
QSizePolicy,
)
from . import strings
from .db import DBManager
# ---------- Activity heatmap ----------
class DateHeatmap(QWidget):
"""
Small calendar heatmap for activity by date.
Data is a mapping: datetime.date -> integer value.
"""
date_clicked = Signal(_dt.date)
def __init__(self, parent=None):
super().__init__(parent)
self._data: Dict[_dt.date, int] = {}
self._start: _dt.date | None = None
self._end: _dt.date | None = None
self._max_value: int = 0
self._cell = 12
self._gap = 3
self._margin_left = 30
self._margin_top = 10
self._margin_bottom = 24
self._margin_right = 10
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
def set_data(self, data: Dict[_dt.date, int]) -> None:
"""Replace dataset and recompute layout."""
self._data = {k: int(v) for k, v in (data or {}).items() if v is not None}
if not self._data:
self._start = self._end = None
self._max_value = 0
else:
earliest = min(self._data.keys())
latest = max(self._data.keys())
self._start = earliest - _dt.timedelta(days=earliest.weekday())
self._end = latest
self._max_value = max(self._data.values()) if self._data else 0
self.updateGeometry()
self.update()
# QWidget overrides ---------------------------------------------------
def sizeHint(self) -> QSize:
if not self._start or not self._end:
height = (
self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap)
)
# some default width
width = (
self._margin_left + self._margin_right + 20 * (self._cell + self._gap)
)
return QSize(width, height)
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7 # ceil
width = (
self._margin_left
+ self._margin_right
+ weeks * (self._cell + self._gap)
+ self._gap
)
height = (
self._margin_top
+ self._margin_bottom
+ 7 * (self._cell + self._gap)
+ self._gap
)
return QSize(width, height)
def minimumSizeHint(self) -> QSize:
sz = self.sizeHint()
return QSize(min(380, sz.width()), sz.height())
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
if not self._start or not self._end:
return
palette = self.palette()
bg_no_data = palette.base().color()
active = palette.highlight().color()
painter.setPen(QPen(Qt.NoPen))
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7
for week in range(weeks):
for dow in range(7):
idx = week * 7 + dow
date = self._start + _dt.timedelta(days=idx)
if date > self._end:
value = 0
else:
value = self._data.get(date, 0)
x = self._margin_left + week * (self._cell + self._gap)
y = self._margin_top + dow * (self._cell + self._gap)
if value <= 0 or self._max_value <= 0:
color = bg_no_data
else:
ratio = max(0.1, min(1.0, value / float(self._max_value)))
color = QColor(active)
# Lighter for low values, darker for high values
lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original
color = color.lighter(lighten)
painter.fillRect(
x,
y,
self._cell,
self._cell,
QBrush(color),
)
painter.setPen(palette.text().color())
fm = painter.fontMetrics()
# --- weekday labels on left -------------------------------------
# Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7):
label = weekday_labels[dow]
text_width = fm.horizontalAdvance(label)
# Center text vertically in the cell
y_center = (
self._margin_top + dow * (self._cell + self._gap) + self._cell / 2
)
baseline_y = int(y_center + fm.ascent() / 2 - fm.descent() / 2)
# Right-align text just to the left of the first column
x = self._margin_left - self._gap - 2 - text_width
painter.drawText(x, baseline_y, label)
prev_month = None
for week in range(weeks):
date = self._start + _dt.timedelta(days=week * 7)
if date > self._end: # pragma: no cover
break
if prev_month == date.month:
continue
prev_month = date.month
label = date.strftime("%b")
x_center = (
self._margin_left + week * (self._cell + self._gap) + self._cell / 2
)
y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent()
text_width = fm.horizontalAdvance(label)
painter.drawText(
int(x_center - text_width / 2),
int(y),
label,
)
painter.end()
def mousePressEvent(self, event):
if event.button() != Qt.LeftButton:
return super().mousePressEvent(event)
# No data = nothing to click
if not self._start or not self._end:
return
# Qt6: position(), older: pos()
pos = event.position() if hasattr(event, "position") else event.pos()
x = pos.x()
y = pos.y()
# Outside grid area (left of weekday labels or above rows)
if x < self._margin_left or y < self._margin_top:
return
cell_span = self._cell + self._gap
col = int((x - self._margin_left) // cell_span) # week index
row = int((y - self._margin_top) // cell_span) # dow (0..6)
# Only 7 rows (MonSun)
if not (0 <= row < 7):
return
# Only as many weeks as we actually have
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7
if col < 0 or col >= weeks:
return
idx = col * 7 + row
date = self._start + _dt.timedelta(days=idx)
# Skip trailing empty cells beyond the last date
if date > self._end:
return
self.date_clicked.emit(date)
# ---------- Statistics dialog itself ----------
class StatisticsDialog(QDialog):
"""
Shows aggregate statistics and the date heatmap with a metric switcher.
"""
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(600)
self.setMinimumHeight(400)
root = QVBoxLayout(self)
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
page_most_tags,
page_most_tags_count,
revisions_by_date,
) = self._gather_stats()
# --- Numeric summary at the top ----------------------------------
form = QFormLayout()
root.addLayout(form)
form.addRow(
strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)),
)
form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
form.addRow(strings._("stats_page_most_revisions"), QLabel(""))
form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
# Unique tag names
form.addRow(
strings._("stats_unique_tags"),
QLabel(str(unique_tags)),
)
if page_most_tags:
form.addRow(
strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
)
else:
form.addRow(strings._("stats_page_most_tags"), QLabel(""))
# --- Heatmap with switcher ---------------------------------------
if words_by_date or revisions_by_date:
group = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group)
# Metric selector
combo_row = QHBoxLayout()
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
self.metric_combo = QComboBox()
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1)
group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setWidget(self._heatmap)
group_layout.addWidget(scroll)
root.addWidget(group)
# Default to "words"
self._apply_metric("words")
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
else:
root.addWidget(QLabel(strings._("stats_no_data")))
# ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None:
if metric == "revisions":
self._heatmap.set_data(self._revisions_by_date)
else:
self._heatmap.set_data(self._words_by_date)
def _on_metric_changed(self, index: int) -> None:
metric = self.metric_combo.currentData()
if metric:
self._apply_metric(metric)
def _gather_stats(self):
return self._db.gather_stats()

View file

@ -1,39 +0,0 @@
from importlib.resources import files
import json
# Get list of locales
root = files("bouquin") / "locales"
_AVAILABLE = tuple(
entry.stem
for entry in root.iterdir()
if entry.is_file() and entry.suffix == ".json"
)
_DEFAULT = "en"
strings = {}
translations = {}
def load_strings(current_locale: str) -> None:
global strings, translations
translations = {}
# read in the locales json
for loc in _AVAILABLE:
data = (root / f"{loc}.json").read_text(encoding="utf-8")
translations[loc] = json.loads(data)
if current_locale not in translations:
current_locale = _DEFAULT
base = translations[_DEFAULT]
cur = translations.get(current_locale, {})
strings = {k: (cur.get(k) or base[k]) for k in base}
def translated(k: str) -> str:
return strings.get(k, k)
_ = translated

View file

@ -1,253 +0,0 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
QInputDialog,
)
from .db import DBManager
from . import strings
from sqlcipher3.dbapi2 import IntegrityError
class TagBrowserDialog(QDialog):
openDateRequested = Signal(str)
tagsModified = Signal()
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(
strings._("tag_browser_title") + " / " + strings._("manage_tags")
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(strings._("tag_browser_instructions"))
instructions.setWordWrap(True)
layout.addWidget(instructions)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
self.tree.itemClicked.connect(self._on_item_clicked)
self.tree.setSortingEnabled(True)
self.tree.sortByColumn(0, Qt.AscendingOrder)
layout.addWidget(self.tree)
# Tag management buttons
btn_row = QHBoxLayout()
self.add_tag_btn = QPushButton("&" + strings._("add_a_tag"))
self.add_tag_btn.clicked.connect(self._add_a_tag)
btn_row.addWidget(self.add_tag_btn)
self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name"))
self.edit_name_btn.clicked.connect(self._edit_tag_name)
self.edit_name_btn.setEnabled(False)
btn_row.addWidget(self.edit_name_btn)
self.change_color_btn = QPushButton("&" + strings._("change_color"))
self.change_color_btn.clicked.connect(self._change_tag_color)
self.change_color_btn.setEnabled(False)
btn_row.addWidget(self.change_color_btn)
self.delete_btn = QPushButton("&" + strings._("delete_tag"))
self.delete_btn.clicked.connect(self._delete_tag)
self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
# Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
# Disable sorting during population for better performance
was_sorting = self.tree.isSortingEnabled()
self.tree.setSortingEnabled(False)
self.tree.clear()
tags = self._db.list_tags()
focus_item = None
for tag_id, name, color in tags:
# Create the tree item
root = QTreeWidgetItem([name, "", ""])
root.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "tag", "id": tag_id, "name": name, "color": color},
)
# Set background color for the second column to show the tag color
bg_color = QColor(color)
root.setBackground(1, bg_color)
# Calculate luminance and set contrasting text color
# Using relative luminance formula (ITU-R BT.709)
luminance = (
0.2126 * bg_color.red()
+ 0.7152 * bg_color.green()
+ 0.0722 * bg_color.blue()
) / 255.0
text_color = QColor(0, 0, 0) if luminance > 0.5 else QColor(255, 255, 255)
root.setForeground(1, text_color)
root.setText(1, color) # Also show the hex code
root.setTextAlignment(1, Qt.AlignCenter)
self.tree.addTopLevelItem(root)
pages = self._db.get_pages_for_tag(name)
for date_iso, _content in pages:
child = QTreeWidgetItem(["", "", date_iso])
child.setData(
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
focus_item = root
if focus_item:
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
# Re-enable sorting after population
self.tree.setSortingEnabled(was_sorting)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Enable/disable buttons based on selection"""
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "tag":
self.edit_name_btn.setEnabled(True)
self.change_color_btn.setEnabled(True)
self.delete_btn.setEnabled(True)
else:
self.edit_name_btn.setEnabled(False)
self.change_color_btn.setEnabled(False)
self.delete_btn.setEnabled(False)
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
def _add_a_tag(self):
"""Add a new tag"""
new_name, ok = QInputDialog.getText(
self, strings._("add_a_tag"), strings._("new_tag_name"), text=""
)
if ok and new_name:
color = QColorDialog.getColor(QColor(), self)
if color.isValid():
try:
self._db.add_tag(new_name, color.name())
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _edit_tag_name(self):
"""Edit the name of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
old_name = data["name"]
color = data["color"]
new_name, ok = QInputDialog.getText(
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
)
if ok and new_name and new_name != old_name:
try:
self._db.update_tag(tag_id, new_name, color)
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _change_tag_color(self):
"""Change the color of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
current_color = data["color"]
color = QColorDialog.getColor(QColor(current_color), self)
if color.isValid():
try:
self._db.update_tag(tag_id, name, color.name())
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _delete_tag(self):
"""Delete the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
# Confirm deletion
reply = QMessageBox.question(
self,
strings._("delete_tag"),
strings._("delete_tag_confirm").format(name=name),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_tag(tag_id)
self._populate(None)
self.tagsModified.emit()

View file

@ -1,259 +0,0 @@
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QVBoxLayout,
QWidget,
QToolButton,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
)
from . import strings
from .db import DBManager
from .flow_layout import FlowLayout
class TagChip(QFrame):
removeRequested = Signal(int) # tag_id
clicked = Signal(str) # tag name
def __init__(
self,
tag_id: int,
name: str,
color: str,
parent: QWidget | None = None,
show_remove: bool = True,
):
super().__init__(parent)
self._id = tag_id
self._name = name
self.setObjectName("TagChip")
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(4)
color_lbl = QLabel()
color_lbl.setFixedSize(10, 10)
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
layout.addWidget(color_lbl)
name_lbl = QLabel(name)
layout.addWidget(name_lbl)
if show_remove:
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
layout.addWidget(btn)
self.setCursor(Qt.PointingHandCursor)
@property
def tag_id(self) -> int:
return self._id
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self.clicked.emit(self._name)
try:
super().mouseReleaseEvent(ev)
except RuntimeError:
pass
class PageTagsWidget(QFrame):
"""
Collapsible per-page tag editor shown in the left sidebar.
Now displays tag chips even when collapsed.
"""
tagActivated = Signal(str) # tag name
tagAdded = Signal() # emitted when a tag is added to trigger autosave
def __init__(self, db: DBManager, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
self._current_date: Optional[str] = None
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + manage button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("tags"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip(strings._("manage_tags"))
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._open_manager)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.manage_btn)
# Body (chips + add line - only visible when expanded)
self.body = QWidget()
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(0, 4, 0, 0)
self.body_layout.setSpacing(4)
# Chips container
self.chip_container = QWidget()
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
self.body_layout.addWidget(self.chip_container)
self.add_edit = QLineEdit()
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
self.add_edit.returnPressed.connect(self._on_add_tag)
# Setup autocomplete
self._setup_autocomplete()
self.body_layout.addWidget(self.add_edit)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# ----- external API ------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
# Only reload tags if expanded
if self.toggle_btn.isChecked():
self._reload_tags()
else:
self._clear_chips() # Clear chips when collapsed
self._setup_autocomplete() # Update autocomplete with all available tags
# ----- internals ---------------------------------------------------
def _setup_autocomplete(self) -> None:
"""Setup autocomplete for the tag input with all existing tags"""
all_tags = [name for _, name, _ in self._db.list_tags()]
completer = QCompleter(all_tags, self.add_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.add_edit.setCompleter(completer)
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
if self._current_date:
self._reload_tags()
self.add_edit.setFocus()
def _clear_chips(self) -> None:
while self.chip_layout.count():
item = self.chip_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _reload_tags(self) -> None:
if not self._current_date:
self._clear_chips()
return
self._clear_chips()
tags = self._db.get_tags_for_page(self._current_date)
for tag_id, name, color in tags:
# Always show remove button since chips only visible when expanded
chip = TagChip(tag_id, name, color, self, show_remove=True)
chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_layout.addWidget(chip)
chip.show()
chip.adjustSize()
# Force complete layout recalculation
self.chip_layout.invalidate()
self.chip_layout.activate()
self.chip_container.updateGeometry()
self.updateGeometry()
# Process pending events to ensure layout is applied
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
def _on_add_tag(self) -> None:
if not self._current_date:
return
# If the completer popup is visible and user pressed Enter,
# the completer will handle it - don't process it again
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
return
new_tag = self.add_edit.text().strip()
if not new_tag:
return
# Get existing tags for current page
existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
# Check for duplicates (case-insensitive)
if any(tag.lower() == new_tag.lower() for tag in existing):
self.add_edit.clear()
return
existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear()
self._reload_tags()
self._setup_autocomplete() # Update autocomplete list
# Signal that a tag was added so main window can trigger autosave
self.tagAdded.emit()
def _remove_tag(self, tag_id: int) -> None:
if not self._current_date:
return
tags = self._db.get_tags_for_page(self._current_date)
remaining = [name for (tid, name, _color) in tags if tid != tag_id]
self._db.set_tags_for_page(self._current_date, remaining)
self._reload_tags()
def _open_manager(self) -> None:
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self._db, self)
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
if dlg.exec():
# Reload tags after manager closes to pick up any changes
if self._current_date:
self._reload_tags()
self._setup_autocomplete()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)

View file

@ -2,9 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from PySide6.QtGui import QPalette, QColor, QGuiApplication
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QObject, Signal
from weakref import WeakSet
class Theme(Enum):
@ -27,9 +26,6 @@ class ThemeManager(QObject):
super().__init__()
self._app = app
self._cfg = cfg
self._current = None
self._calendars: "WeakSet[QCalendarWidget]" = WeakSet()
self._lock_overlays: "WeakSet[QWidget]" = WeakSet()
# Follow OS if supported (Qt 6+)
hints = QGuiApplication.styleHints()
@ -39,20 +35,6 @@ class ThemeManager(QObject):
and self.apply(self._cfg.theme)
)
def _is_system_dark(self) -> bool:
pal = QGuiApplication.palette()
# Heuristic: dark windows/backgrounds mean dark system theme
return pal.color(QPalette.Window).lightness() < 128
def _restyle_registered(self) -> None:
for cal in list(self._calendars):
if cal is not None:
self._apply_calendar_theme(cal)
for overlay in list(self._lock_overlays):
if overlay is not None:
self._apply_lock_overlay_theme(overlay)
def current(self) -> Theme:
return self._cfg.theme
@ -61,34 +43,28 @@ class ThemeManager(QObject):
self.apply(theme)
def apply(self, theme: Theme):
# Resolve "system" into a concrete theme
resolved = theme
# Resolve "system"
if theme == Theme.SYSTEM:
resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT
if resolved == Theme.DARK:
pal = self._dark_palette()
else:
pal = self._light_palette()
hints = QGuiApplication.styleHints()
scheme = getattr(hints, "colorScheme", None)
if callable(scheme):
scheme = hints.colorScheme()
# 0=Light, 1=Dark; fall back to Light
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
# Always use Fusion so palette applies consistently cross-platform
QApplication.setStyle("Fusion")
self._app.setStyle("Fusion")
self._app.setPalette(pal)
self._current = resolved
# Re-style any registered widgets
self._restyle_registered()
self.themeChanged.emit(self._current)
if theme == Theme.DARK:
pal = self._dark_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
else:
pal = self._light_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
def register_calendar(self, cal: QCalendarWidget) -> None:
"""Start theming calendar and keep it in sync with theme changes."""
self._calendars.add(cal)
self._apply_calendar_theme(cal)
def register_lock_overlay(self, overlay: QWidget) -> None:
"""Start theming lock overlay and keep it in sync with theme changes."""
self._lock_overlays.add(overlay)
self._apply_lock_overlay_theme(overlay)
self.themeChanged.emit(theme)
# ----- Palettes -----
def _dark_palette(self) -> QPalette:
@ -99,24 +75,17 @@ class ThemeManager(QObject):
disabled = QColor(127, 127, 127)
focus = QColor(42, 130, 218)
# Base surfaces
pal.setColor(QPalette.Window, window)
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.Base, base)
pal.setColor(QPalette.AlternateBase, window)
# Text
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.ToolTipBase, window)
pal.setColor(QPalette.ToolTipText, text)
pal.setColor(QPalette.Text, text)
pal.setColor(QPalette.PlaceholderText, disabled)
pal.setColor(QPalette.ButtonText, text)
# Buttons/frames
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.ButtonText, text)
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
# Links / selection
pal.setColor(QPalette.Highlight, focus)
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
@ -125,136 +94,11 @@ class ThemeManager(QObject):
return pal
def _light_palette(self) -> QPalette:
pal = QPalette()
# Base surfaces
pal.setColor(QPalette.Window, QColor("#ffffff"))
pal.setColor(QPalette.Base, QColor("#ffffff"))
pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5"))
# Text
pal.setColor(QPalette.WindowText, QColor("#000000"))
pal.setColor(QPalette.Text, QColor("#000000"))
pal.setColor(QPalette.ButtonText, QColor("#000000"))
# Buttons/frames
pal.setColor(QPalette.Button, QColor("#f0f0f0"))
pal.setColor(QPalette.Mid, QColor("#9e9e9e"))
# Links / selection
pal.setColor(QPalette.Highlight, QColor("#1a73e8"))
pal.setColor(QPalette.HighlightedText, QColor("#ffffff"))
pal.setColor(QPalette.Link, QColor("#1a73e8"))
pal.setColor(QPalette.LinkVisited, QColor("#6b4ca5"))
# Let Qt provide its default light palette, but nudge a couple roles
pal = self._app.style().standardPalette()
pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
pal.setColor(
QPalette.Link, QColor("#1a73e8")
) # Light blue for links in light mode
return pal
def _apply_calendar_theme(self, cal: QCalendarWidget) -> None:
"""Use orange accents on the calendar in dark mode only."""
app_pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
highlight_css = Theme.ORANGE_ANCHOR.value
highlight = QColor(highlight_css)
black = QColor(0, 0, 0)
# Per-widget palette: selection color inside the date grid
pal = cal.palette()
pal.setColor(QPalette.Highlight, highlight)
pal.setColor(QPalette.HighlightedText, black)
cal.setPalette(pal)
# Stylesheet: nav bar + selected-day background
cal.setStyleSheet(self._calendar_qss(highlight_css))
else:
# Back to app defaults in light/system-light
cal.setPalette(app_pal)
cal.setStyleSheet("")
cal.update()
def _calendar_qss(self, highlight_css: str) -> str:
return f"""
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
QCalendarWidget QToolButton {{ color: black; }}
QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
/* Selected day color in the table view */
QCalendarWidget QTableView:enabled {{
selection-background-color: {highlight_css};
selection-color: black;
}}
/* Keep weekday header readable */
QCalendarWidget QTableView QHeaderView::section {{
background: transparent;
color: palette(windowText);
}}
"""
def _apply_lock_overlay_theme(self, overlay: QWidget) -> None:
"""
Style the LockOverlay (objectName 'LockOverlay') using theme colors.
Dark: opaque black bg, orange accent; Light: translucent scrim, palette-driven colors.
"""
pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
# Use the link color as the accent
accent = pal.color(QPalette.Link)
r, g, b = accent.red(), accent.green(), accent.blue()
accent_hex = accent.name()
qss = f"""
#LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
else:
qss = """
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
overlay.setStyleSheet(qss)
overlay.update()

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,6 @@ from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtWidgets import QToolBar
from . import strings
class ToolBar(QToolBar):
boldRequested = Signal()
@ -18,108 +16,83 @@ class ToolBar(QToolBar):
checkboxesRequested = Signal()
historyRequested = Signal()
insertImageRequested = Signal()
alarmRequested = Signal()
timerRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
def __init__(self, parent=None):
super().__init__(strings._("toolbar_format"), parent)
self.setObjectName(strings._("toolbar_format"))
super().__init__("Format", parent)
self.setObjectName("Format")
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
self._build_actions()
self._apply_toolbar_styles()
def _build_actions(self):
self.actBold = QAction("B", self)
self.actBold.setToolTip(strings._("toolbar_bold"))
self.actBold.setToolTip("Bold")
self.actBold.setCheckable(True)
self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested)
self.actItalic = QAction("I", self)
self.actItalic.setToolTip(strings._("toolbar_italic"))
self.actItalic.setToolTip("Italic")
self.actItalic.setCheckable(True)
self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested)
self.actStrike = QAction("S", self)
self.actStrike.setToolTip(strings._("toolbar_strikethrough"))
self.actStrike.setToolTip("Strikethrough")
self.actStrike.setCheckable(True)
self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested)
self.actCode = QAction("</>", self)
self.actCode.setToolTip(strings._("toolbar_code_block"))
self.actCode.setToolTip("Code block")
self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested)
# Headings
self.actH1 = QAction("H1", self)
self.actH1.setToolTip(strings._("toolbar_heading") + " 1")
self.actH1.setToolTip("Heading 1")
self.actH1.setCheckable(True)
self.actH1.setShortcut("Ctrl+1")
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
self.actH2 = QAction("H2", self)
self.actH2.setToolTip(strings._("toolbar_heading") + " 2")
self.actH2.setToolTip("Heading 2")
self.actH2.setCheckable(True)
self.actH2.setShortcut("Ctrl+2")
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
self.actH3 = QAction("H3", self)
self.actH3.setToolTip(strings._("toolbar_heading") + " 3")
self.actH3.setToolTip("Heading 3")
self.actH3.setCheckable(True)
self.actH3.setShortcut("Ctrl+3")
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
self.actNormal = QAction("P", self)
self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
self.actNormal = QAction("N", self)
self.actNormal.setToolTip("Normal paragraph text")
self.actNormal.setCheckable(True)
self.actNormal.setShortcut("Ctrl+.")
self.actNormal.setShortcut("Ctrl+N")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
self.actFontSmaller = QAction("P-", self)
self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller"))
self.actFontSmaller.setShortcut("Ctrl+Shift+-")
self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested)
self.actFontLarger = QAction("P+", self)
self.actFontLarger.setToolTip(strings._("toolbar_font_larger"))
self.actFontLarger.setShortcut("Ctrl+Shift+=")
self.actFontLarger.triggered.connect(self.fontSizeLargerRequested)
# Lists
self.actBullets = QAction("", self)
self.actBullets.setToolTip(strings._("toolbar_bulleted_list"))
self.actBullets.setToolTip("Bulleted list")
self.actBullets.setCheckable(True)
self.actBullets.triggered.connect(self.bulletsRequested)
self.actNumbers = QAction("1.", self)
self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
self.actNumbers.setToolTip("Numbered list")
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.setToolTip("Toggle checkboxes")
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images
self.actInsertImg = QAction("📸", self)
self.actInsertImg.setToolTip(strings._("insert_images"))
self.actInsertImg = QAction("Image", self)
self.actInsertImg.setToolTip("Insert image")
self.actInsertImg.setShortcut("Ctrl+Shift+I")
self.actInsertImg.triggered.connect(self.insertImageRequested)
# History button
self.actHistory = QAction("🔁", self)
self.actHistory.setToolTip(strings._("history"))
self.actHistory = QAction("History", self)
self.actHistory.triggered.connect(self.historyRequested)
# Alarm / reminder
self.actAlarm = QAction("", self)
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
self.actAlarm.triggered.connect(self.alarmRequested)
# Focus timer
self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.triggered.connect(self.timerRequested)
# Set exclusive buttons in QActionGroups
self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True)
@ -151,14 +124,10 @@ class ToolBar(QToolBar):
self.actH2,
self.actH3,
self.actNormal,
self.actFontSmaller,
self.actFontLarger,
self.actBullets,
self.actNumbers,
self.actCheckboxes,
self.actInsertImg,
self.actAlarm,
self.actTimer,
self.actHistory,
]
)
@ -175,19 +144,14 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actH1, "H1")
self._style_letter_button(self.actH2, "H2")
self._style_letter_button(self.actH3, "H3")
self._style_letter_button(self.actNormal, "P")
self._style_letter_button(self.actFontSmaller, "P-")
self._style_letter_button(self.actFontLarger, "P+")
self._style_letter_button(self.actNormal, "N")
# Lists
self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.")
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
# History
self._style_letter_button(self.actHistory, "🔁")
self._style_letter_button(self.actHistory, "View History")
def _style_letter_button(
self,

View file

@ -1,412 +0,0 @@
from __future__ import annotations
import importlib.metadata
import os
import re
import subprocess # nosec
import tempfile
from pathlib import Path
import requests
from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QWidget,
QProgressDialog,
)
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
from PySide6.QtSvg import QSvgRenderer
from .settings import APP_NAME
from . import strings
# Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt"
# Name of the installed distribution according to pyproject.toml
# (used with importlib.metadata.version)
DIST_NAME = "bouquin"
# Base URL where AppImages are hosted
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
# Where we expect to find the bundled public key, relative to the *installed* package.
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
class VersionChecker:
"""
Handles:
* showing the version dialog
* checking for updates
* downloading & verifying a new AppImage
All dialogs use `parent` as their parent widget.
"""
def __init__(self, parent: QWidget | None = None):
self._parent = parent
# ---------- Version helpers ---------- #
def _logo_pixmap(self, logical_size: int = 96) -> QPixmap:
"""
Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp.
"""
svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg"
# Logical size (what Qt layouts see)
dpr = QGuiApplication.primaryScreen().devicePixelRatio()
img_size = int(logical_size * dpr)
image = QImage(img_size, img_size, QImage.Format_ARGB32)
image.fill(Qt.transparent)
renderer = QSvgRenderer(str(svg_path))
painter = QPainter(image)
renderer.render(painter)
painter.end()
pixmap = QPixmap.fromImage(image)
pixmap.setDevicePixelRatio(dpr)
return pixmap
def current_version(self) -> str:
"""
Return the current app version as reported by importlib.metadata
"""
try:
return importlib.metadata.version(DIST_NAME)
except importlib.metadata.PackageNotFoundError:
# Fallback for editable installs / dev trees
return "0.0.0"
@staticmethod
def _parse_version(v: str) -> tuple[int, ...]:
"""
Very small helper to compare simple semantic versions like 1.2.3.
Extracts numeric components and returns them as a tuple.
"""
parts = re.findall(r"\d+", v)
if not parts:
return (0,)
return tuple(int(p) for p in parts)
def _is_newer_version(self, available: str, current: str) -> bool:
"""
True if `available` > `current` according to _parse_version.
"""
return self._parse_version(available) > self._parse_version(current)
# ---------- Public entrypoint for Help → Version ---------- #
def show_version_dialog(self) -> None:
"""
Show the Version dialog with a 'Check for updates' button.
"""
version = self.current_version()
version_formatted = f"{APP_NAME} {version}"
box = QMessageBox(self._parent)
box.setWindowTitle(strings._("version"))
box.setIconPixmap(self._logo_pixmap(96))
box.setText(version_formatted)
check_button = box.addButton(
strings._("check_for_updates"), QMessageBox.ActionRole
)
box.addButton(QMessageBox.Close)
box.exec()
if box.clickedButton() is check_button:
self.check_for_updates()
# ---------- Core update logic ---------- #
def check_for_updates(self) -> None:
"""
Fetch VERSION_URL, compare against the current version, and optionally
download + verify a new AppImage.
"""
current = self.current_version()
try:
resp = requests.get(VERSION_URL, timeout=10)
resp.raise_for_status()
available_raw = resp.text.strip()
except Exception as e:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("could_not_check_for_updates") + str(e),
)
return
if not available_raw:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("update_server_returned_an_empty_version_string"),
)
return
if not self._is_newer_version(available_raw, current):
QMessageBox.information(
self._parent,
strings._("update"),
strings._("you_are_running_the_latest_version") + f"({current}).",
)
return
# Newer version is available
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw)
# ---------- Download + verification helpers ---------- #
def _download_file(
self,
url: str,
dest_path: Path,
timeout: int = 30,
progress: QProgressDialog | None = None,
label: str | None = None,
) -> None:
"""
Stream a URL to a local file, optionally updating a QProgressDialog.
If the user cancels via the dialog, raises RuntimeError.
"""
resp = requests.get(url, timeout=timeout, stream=True)
resp.raise_for_status()
dest_path.parent.mkdir(parents=True, exist_ok=True)
total_bytes: int | None = None
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
total_bytes = int(content_length)
except ValueError:
total_bytes = None
if progress is not None:
progress.setLabelText(
label or strings._("downloading") + f" {dest_path.name}..."
)
# Unknown size → busy indicator; known size → real range
if total_bytes is not None and total_bytes > 0:
progress.setRange(0, total_bytes)
else:
progress.setRange(0, 0) # pragma: no cover
progress.setValue(0)
progress.show()
QApplication.processEvents()
downloaded = 0
with dest_path.open("wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if not chunk:
continue # pragma: no cover
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
if total_bytes is not None and total_bytes > 0:
progress.setValue(downloaded)
else:
# Just bump a little so the dialog looks alive
progress.setValue(progress.value() + 1) # pragma: no cover
QApplication.processEvents()
if progress.wasCanceled():
raise RuntimeError(strings._("download_cancelled"))
if progress is not None and total_bytes is not None and total_bytes > 0:
progress.setValue(total_bytes)
QApplication.processEvents()
def _download_and_verify_appimage(self, version: str) -> None:
"""
Download the AppImage + its GPG signature to the user's Downloads dir,
then verify it with a bundled public key.
"""
# Where to put the file
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
if not download_dir:
download_dir = os.path.expanduser("~/Downloads")
download_dir = Path(download_dir)
download_dir.mkdir(parents=True, exist_ok=True)
# Construct AppImage filename and URLs
appimage_path = download_dir / "Bouquin.AppImage"
sig_path = Path(str(appimage_path) + ".asc")
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
sig_url = f"{appimage_url}.asc"
# Progress dialog covering both downloads
progress = QProgressDialog(
"Downloading update...",
"Cancel",
0,
100,
self._parent,
)
progress.setWindowTitle(strings._("update"))
progress.setWindowModality(Qt.WindowModal)
progress.setAutoClose(False)
progress.setAutoReset(False)
try:
# AppImage download
self._download_file(
appimage_url,
appimage_path,
progress=progress,
label=strings._("downloading") + " Bouquin.AppImage...",
)
# Signature download (usually tiny, but we still show it)
self._download_file(
sig_url,
sig_path,
progress=progress,
label=strings._("downloading") + " signature...",
)
except RuntimeError:
# User cancelled
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
progress.close()
QMessageBox.information(
self._parent,
strings._("update"),
strings._("download_cancelled"),
)
return
except Exception as e:
# Other error
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
progress.close()
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("failed_to_download_update") + str(e),
)
return
progress.close()
# Load the bundled public key
try:
pkg, *rel = GPG_PUBKEY_RESOURCE
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
except Exception as e: # pragma: no cover
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_read_bundled_gpg_public_key") + str(e),
)
# On failure, delete the downloaded files for safety
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError: # pragma: no cover
pass
return
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
try:
with tempfile.TemporaryDirectory() as gnupg_home:
pubkey_path = Path(gnupg_home) / "pubkey.asc"
pubkey_path.write_bytes(pubkey_bytes)
# Import the key
subprocess.run(
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
# Verify the signature
subprocess.run(
[
"gpg",
"--homedir",
gnupg_home,
"--verify",
str(sig_path),
str(appimage_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
except FileNotFoundError:
# gpg not installed / not on PATH
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_find_gpg_executable"),
)
return
except subprocess.CalledProcessError as e:
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("gpg_signature_verification_failed")
+ e.stderr.decode(errors="ignore"),
)
return
# Success
QMessageBox.information(
self._parent,
strings._("update"),
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
)

View file

@ -1,259 +0,0 @@
#!/usr/bin/env python3
import argparse
import ast
import json
from pathlib import Path
from typing import Dict, Set
BASE_DIR = Path(__file__).resolve().parent / "bouquin"
LOCALES_DIR = BASE_DIR / "locales"
def load_json_keys(locale: str) -> Set[str]:
"""Load all keys from the given locale JSON file."""
path = LOCALES_DIR / f"{locale}.json"
with path.open(encoding="utf-8") as f:
data = json.load(f)
return set(data.keys())
class KeyParamFinder(ast.NodeVisitor):
"""
First pass:
For each function/method, figure out which parameters are later passed
into _(), translated(), or strings._().
Example: in your _prompt_name, it discovers that title_key and label_key
are translation-key parameters.
"""
def __init__(self) -> None:
# func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]}
self.func_info: Dict[str, dict] = {}
self.current_func_name_stack: list[str] = []
self.current_param_positions_stack: list[Dict[str, int]] = []
self.current_class_stack: list[str] = []
# Track when we're inside a class so we can treat "self" specially
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.current_class_stack.append(node.name)
self.generic_visit(node)
self.current_class_stack.pop()
def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
funcname = node.name
params = [arg.arg for arg in node.args.args]
# If we're inside a class and there is at least one param,
# assume the first one is "self"/"cls" and is implicit at call sites.
is_method = bool(self.current_class_stack) and len(params) > 0
param_positions: Dict[str, int] = {}
for i, name in enumerate(params):
if is_method and i == 0:
# skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...)
continue
call_index = i - 1 if is_method else i
param_positions[name] = call_index
self.current_func_name_stack.append(funcname)
self.current_param_positions_stack.append(param_positions)
self.func_info.setdefault(
funcname,
{
"param_positions": param_positions,
"key_param_positions": set(),
},
)
# If the function name is reused, last definition wins
self.func_info[funcname]["param_positions"] = param_positions
def _exit_function(self) -> None:
self.current_func_name_stack.pop()
self.current_param_positions_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_Call(self, node: ast.Call) -> None:
# Only care about calls *inside* functions
if not self.current_func_name_stack:
return self.generic_visit(node)
func = node.func
func_name: str | None = None
if isinstance(func, ast.Name):
func_name = func.id
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
# e.g. strings._(...)
func_name = f"{func.value.id}.{func.attr}"
# Is this a translation call?
if func_name in {"_", "translated", "strings._"}:
cur_name = self.current_func_name_stack[-1]
param_positions = self.current_param_positions_stack[-1]
# Positional first arg
if node.args:
first = node.args[0]
if isinstance(first, ast.Name):
pname = first.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
# Keyword args, e.g. strings._(key=title_key)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Name):
pname = kw.value.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
self.generic_visit(node)
class UsedKeyCollector(ast.NodeVisitor):
"""
Second pass:
- Collect string literals passed directly to _()/translated()/strings._()
- Collect string literals passed into parameters that we know are
"translation-key parameters" of wrapper functions/methods.
"""
def __init__(self, func_info: Dict[str, dict]) -> None:
self.func_info = func_info
self.used_keys: Set[str] = set()
def visit_Call(self, node: ast.Call) -> None:
func = node.func
def full_name(f: ast.expr) -> str | None:
if isinstance(f, ast.Name):
return f.id
if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
return f"{f.value.id}.{f.attr}"
return None
func_full = full_name(func)
# 1) Direct translation calls like _("key") or strings._("key")
if func_full in {"_", "translated", "strings._"}:
if node.args:
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
self.used_keys.add(first.value)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Constant) and isinstance(
kw.value.value, str
):
self.used_keys.add(kw.value.value)
# 2) Wrapper calls: functions whose params we know are translation-key params
called_base_name: str | None = None
if isinstance(func, ast.Name):
called_base_name = func.id
elif isinstance(func, ast.Attribute):
called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name"
if called_base_name in self.func_info:
info = self.func_info[called_base_name]
param_positions: Dict[str, int] = info["param_positions"]
key_positions: Set[int] = info["key_param_positions"]
# positional args
for idx, arg in enumerate(node.args):
if (
idx in key_positions
and isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
):
self.used_keys.add(arg.value)
# keyword args
for kw in node.keywords or []:
if kw.arg is None:
continue # **kwargs, ignore
param_name = kw.arg
if param_name in param_positions:
idx = param_positions[param_name]
if idx in key_positions:
val = kw.value
if isinstance(val, ast.Constant) and isinstance(val.value, str):
self.used_keys.add(val.value)
self.generic_visit(node)
def collect_used_keys() -> Set[str]:
"""Parse all .py files and collect all translation keys used."""
trees: list[ast.AST] = []
# Read and parse all Python files in this folder
for path in BASE_DIR.glob("*.py"):
# Optionally skip this script itself
if path.name == Path(__file__).name:
continue
src = path.read_text(encoding="utf-8")
tree = ast.parse(src, filename=str(path))
trees.append(tree)
# First pass: find which parameters are translation-key params
finder = KeyParamFinder()
for tree in trees:
finder.visit(tree)
# Second pass: collect string literals passed to those parameters
collector = UsedKeyCollector(finder.func_info)
for tree in trees:
collector.visit(tree)
return collector.used_keys
def main() -> None:
parser = argparse.ArgumentParser(
description="Find missing or unused strings for a given locale"
)
parser.add_argument(
"--locale",
type=str,
default="en",
help="Locale key e.g en, fr, it",
)
args = parser.parse_args()
json_keys = load_json_keys(args.locale)
used_keys = collect_used_keys()
unused_keys = sorted(json_keys - used_keys)
missing_in_json = sorted(used_keys - json_keys)
print("=== Unused keys in JSON (present in locales but never used in code) ===")
if unused_keys:
for k in unused_keys:
print(" ", k)
else:
print(" (none)")
print("\n=== Keys used in code but missing from JSON ===")
if missing_in_json:
for k in missing_in_json:
print(" ", k)
else:
print(" (none)")
if __name__ == "__main__":
main()

444
poetry.lock generated
View file

@ -1,138 +1,5 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "certifi"
version = "2025.11.12"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
files = [
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
]
[[package]]
name = "colorama"
version = "0.4.6"
@ -146,103 +13,115 @@ files = [
[[package]]
name = "coverage"
version = "7.12.0"
version = "7.10.7"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
python-versions = ">=3.9"
files = [
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"},
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"},
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"},
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"},
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"},
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"},
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"},
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"},
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"},
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"},
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"},
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"},
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"},
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"},
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"},
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"},
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"},
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"},
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"},
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"},
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"},
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"},
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"},
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"},
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"},
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"},
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"},
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"},
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"},
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"},
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"},
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"},
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"},
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"},
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"},
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"},
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"},
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"},
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"},
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"},
{file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"},
{file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"},
{file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"},
{file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"},
{file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"},
{file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"},
]
[package.dependencies]
@ -251,20 +130,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
[package.extras]
toml = ["tomli"]
[[package]]
name = "desktop-entry-lib"
version = "5.0"
description = "A library for working with .desktop files"
optional = false
python-versions = ">=3.10"
files = [
{file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
{file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
]
[package.extras]
xdg-desktop-portal = ["jeepney"]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@ -283,45 +148,16 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
test = ["pytest (>=6)"]
[[package]]
name = "idna"
version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)"
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "markdown"
version = "3.10"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.10"
files = [
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"},
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "packaging"
version = "25.0"
@ -362,22 +198,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyproject-appimage"
version = "4.2"
description = "Generate AppImages from your Python projects"
optional = false
python-versions = ">=3.9"
files = [
{file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
]
[package.dependencies]
desktop-entry-lib = "*"
requests = "*"
tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6"
version = "6.10.0"
@ -511,27 +331,6 @@ typing_extensions = "*"
dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx_rtd_theme"]
[[package]]
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "shiboken6"
version = "6.10.0"
@ -739,24 +538,7 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.14"
content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"
python-versions = ">=3.9,<3.14"
content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4"

View file

@ -1,20 +1,16 @@
[tool.poetry]
name = "bouquin"
version = "0.5.2"
version = "0.2.0"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"
license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin"
packages = [{ include = "bouquin" }]
include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"]
[tool.poetry.dependencies]
python = ">=3.10,<3.14"
python = ">=3.9,<3.14"
pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0"
requests = "^2.32.5"
markdown = "^3.10"
[tool.poetry.scripts]
bouquin = "bouquin.__main__:main"
@ -25,20 +21,6 @@ pytest-qt = "^4.5.0"
pytest-mock = "^3.15.1"
pytest-cov = "^7.0.0"
[tool.poetry.group.dev.dependencies]
pyproject-appimage = "^4.2"
[tool.pyproject-appimage]
script = "bouquin"
output = "Bouquin.AppImage"
icon = "bouquin/icons/bouquin.svg"
rename-icon = "bouquin.png"
desktop-entry = "bouquin.desktop"
[tool.vulture]
paths = ["bouquin", "vulture_ignorelist.py"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -1,19 +1,10 @@
#!/bin/bash
set -eo pipefail
set -e
rm -rf dist
# Publish to Pypi
poetry build
poetry publish
# Make AppImage
sudo apt-get install libfuse-dev
poetry run pyproject-appimage
mv Bouquin.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
echo "Don't forget to update version string on remote server."

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
screenshot_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

View file

@ -33,19 +33,10 @@ def isolate_qsettings(tmp_path_factory):
def tmp_db_cfg(tmp_path):
from bouquin.db import DBConfig
default_db = tmp_path / "notebook.db"
db_path = tmp_path / "notebook.db"
key = "test-secret-key"
return DBConfig(
path=default_db,
key=key,
idle_minutes=0,
theme="light",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
)

View file

@ -1,324 +0,0 @@
import bouquin.bug_report_dialog as bugmod
from bouquin.bug_report_dialog import BugReportDialog
from bouquin import strings
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
def test_bug_report_truncates_text_to_max_chars(qtbot):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
# Make a string longer than the allowed maximum
long_text = "x" * (max_chars + 50)
# Setting the text should trigger textChanged -> _enforce_max_length
dlg.text_edit.setPlainText(long_text)
# Let Qt process the signal/slot if needed
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
assert len(current) == max_chars
assert current == long_text[:max_chars]
def test_bug_report_allows_up_to_max_chars_unchanged(qtbot):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
exact_text = "y" * max_chars
dlg.text_edit.setPlainText(exact_text)
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
# Should not be trimmed if it's exactly the limit
assert len(current) == max_chars
assert current == exact_text
def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
# Non-empty message so we don't hit the "empty" warning branch
dlg.text_edit.setPlainText("Hello, something broke.")
qtbot.wait(10)
# Make version() deterministic
def fake_version(pkg_name):
assert pkg_name == "bouquin"
return "1.2.3"
monkeypatch.setattr(
bugmod.importlib.metadata, "version", fake_version, raising=True
)
# Capture the POST call and fake a 201 Created response
calls = {}
class DummyResp:
status_code = 201
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
# Capture information / critical message boxes
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
# Don't actually close the dialog in the test; just record that accept() was called
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
# Call the send logic directly
dlg._send()
# --- Assertions ---------------------------------------------------------
# POST was called with the expected URL and JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Hello, something broke."
assert calls["json"]["version"] == "1.2.3"
# No attachment fields expected any more
# Success path: information dialog shown, critical not shown
assert "title" in info_called
assert "text" in info_called
assert crit_called == {}
# Dialog accepted
assert accepted.get("called") is True
def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
qtbot, monkeypatch
):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
dlg.text_edit.setPlainText("Broken again.")
qtbot.wait(10)
# Stub version() again
monkeypatch.setattr(
bugmod.importlib.metadata,
"version",
lambda name: "9.9.9",
raising=True,
)
# Fake a non-201 response (e.g. 500)
calls = {}
class DummyResp:
status_code = 500
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
dlg._send()
# POST still called with JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Broken again."
assert calls["json"]["version"] == "9.9.9"
# Failure path: critical dialog shown, information not shown
assert crit_called # non-empty
assert info_called == {}
# Dialog should NOT be accepted on failure
assert accepted.get("called") is not True
def test_bug_report_dialog_text_limit_clamps_cursor(qtbot):
"""Test that cursor position is clamped when text exceeds limit."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set text that exceeds MAX_CHARS
max_chars = dialog.MAX_CHARS
long_text = "A" * (max_chars + 100)
# Set text and move cursor to end
dialog.text_edit.setPlainText(long_text)
dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End)
# Text should be truncated
assert len(dialog.text_edit.toPlainText()) == max_chars
# Cursor should be clamped to max position
final_cursor = dialog.text_edit.textCursor()
assert final_cursor.position() <= max_chars
def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch):
"""Test that sending empty report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Clear any text
dialog.text_edit.clear()
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
# Try to send empty report
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch):
"""Test that sending whitespace-only report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set whitespace only
dialog.text_edit.setPlainText(" \n\n \t\t ")
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_network_error(qtbot, monkeypatch):
"""Test handling network error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise exception
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.ConnectionError("Network error")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]
def test_bug_report_dialog_timeout_error(qtbot, monkeypatch):
"""Test handling timeout error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise timeout
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.Timeout("Request timed out")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]

View file

@ -1,398 +0,0 @@
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
from PySide6.QtGui import QTextCharFormat, QFont
def test_get_language_patterns_python(app):
"""Test getting highlighting patterns for Python."""
patterns = CodeHighlighter.get_language_patterns("python")
assert len(patterns) > 0
# Should have comment pattern
assert any("#" in p[0] for p in patterns)
# Should have string patterns
assert any('"' in p[0] for p in patterns)
# Should have keyword patterns
assert any("keyword" == p[1] for p in patterns)
def test_get_language_patterns_javascript(app):
"""Test getting highlighting patterns for JavaScript."""
patterns = CodeHighlighter.get_language_patterns("javascript")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_php(app):
"""Test getting highlighting patterns for PHP."""
patterns = CodeHighlighter.get_language_patterns("php")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_bash(app):
"""Test getting highlighting patterns for Bash."""
patterns = CodeHighlighter.get_language_patterns("bash")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have bash keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_html(app):
"""Test getting highlighting patterns for HTML."""
patterns = CodeHighlighter.get_language_patterns("html")
assert len(patterns) > 0
# Should have tag pattern
assert any("tag" == p[1] for p in patterns)
# Should have HTML comment pattern
assert any("<!--" in p[0] for p in patterns)
def test_get_language_patterns_css(app):
"""Test getting highlighting patterns for CSS."""
patterns = CodeHighlighter.get_language_patterns("css")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have CSS properties as keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_unknown_language(app):
"""Test getting patterns for an unknown language."""
patterns = CodeHighlighter.get_language_patterns("unknown-lang")
# Should still return basic patterns (strings, numbers)
assert len(patterns) > 0
assert any("string" == p[1] for p in patterns)
assert any("number" == p[1] for p in patterns)
def test_get_language_patterns_case_insensitive(app):
"""Test that language matching is case insensitive."""
patterns_lower = CodeHighlighter.get_language_patterns("python")
patterns_upper = CodeHighlighter.get_language_patterns("PYTHON")
patterns_mixed = CodeHighlighter.get_language_patterns("PyThOn")
assert len(patterns_lower) == len(patterns_upper)
assert len(patterns_lower) == len(patterns_mixed)
def test_get_format_for_type_keyword(app):
"""Test getting format for keyword type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
assert fmt.fontWeight() == QFont.Weight.Bold
assert fmt.foreground().color().blue() > 0 # Should have blue-ish color
def test_get_format_for_type_string(app):
"""Test getting format for string type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("string", base_format)
# Should have orangish color
color = fmt.foreground().color()
assert color.red() > 100
def test_get_format_for_type_comment(app):
"""Test getting format for comment type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("comment", base_format)
assert fmt.fontItalic() is True
# Should have greenish color
color = fmt.foreground().color()
assert color.green() > 0
def test_get_format_for_type_number(app):
"""Test getting format for number type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("number", base_format)
# Should have some color
color = fmt.foreground().color()
assert color.isValid()
def test_get_format_for_type_tag(app):
"""Test getting format for HTML tag type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("tag", base_format)
# Should have cyan-ish color
color = fmt.foreground().color()
assert color.green() > 0
assert color.blue() > 0
def test_get_format_for_type_unknown(app):
"""Test getting format for unknown type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("unknown", base_format)
# Should return a valid format (based on base_format)
assert fmt is not None
def test_code_block_metadata_init(app):
"""Test CodeBlockMetadata initialization."""
metadata = CodeBlockMetadata()
assert len(metadata._block_languages) == 0
def test_code_block_metadata_set_get_language(app):
"""Test setting and getting language for a block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(5, "javascript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(5) == "javascript"
assert metadata.get_language(10) is None
def test_code_block_metadata_set_language_case_normalization(app):
"""Test that language is normalized to lowercase."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "PYTHON")
metadata.set_language(1, "JavaScript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(1) == "javascript"
def test_code_block_metadata_serialize_empty(app):
"""Test serializing empty metadata."""
metadata = CodeBlockMetadata()
result = metadata.serialize()
assert result == ""
def test_code_block_metadata_serialize(app):
"""Test serializing metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(3, "javascript")
result = metadata.serialize()
assert "<!-- code-langs:" in result
assert "0:python" in result
assert "3:javascript" in result
assert "-->" in result
def test_code_block_metadata_serialize_sorted(app):
"""Test that serialized metadata is sorted by block number."""
metadata = CodeBlockMetadata()
metadata.set_language(5, "python")
metadata.set_language(2, "javascript")
metadata.set_language(8, "bash")
result = metadata.serialize()
# Find positions in string
pos_2 = result.find("2:")
pos_5 = result.find("5:")
pos_8 = result.find("8:")
# Should be in order
assert pos_2 < pos_5 < pos_8
def test_code_block_metadata_deserialize(app):
"""Test deserializing metadata."""
metadata = CodeBlockMetadata()
text = (
"Some content\n<!-- code-langs: 0:python,3:javascript,5:bash -->\nMore content"
)
metadata.deserialize(text)
assert metadata.get_language(0) == "python"
assert metadata.get_language(3) == "javascript"
assert metadata.get_language(5) == "bash"
def test_code_block_metadata_deserialize_empty(app):
"""Test deserializing from text without metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python") # Set some initial data
text = "Just some regular text with no metadata"
metadata.deserialize(text)
# Should clear existing data
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_format(app):
"""Test deserializing with invalid format."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: invalid,format,here -->"
metadata.deserialize(text)
# Should handle gracefully, resulting in empty or minimal data
# Pairs without ':' should be skipped
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_block_number(app):
"""Test deserializing with invalid block number."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: abc:python,3:javascript -->"
metadata.deserialize(text)
# Should skip invalid block number 'abc'
assert metadata.get_language(3) == "javascript"
assert "abc" not in str(metadata._block_languages)
def test_code_block_metadata_round_trip(app):
"""Test serializing and deserializing preserves data."""
metadata1 = CodeBlockMetadata()
metadata1.set_language(0, "python")
metadata1.set_language(2, "javascript")
metadata1.set_language(7, "bash")
serialized = metadata1.serialize()
metadata2 = CodeBlockMetadata()
metadata2.deserialize(serialized)
assert metadata2.get_language(0) == "python"
assert metadata2.get_language(2) == "javascript"
assert metadata2.get_language(7) == "bash"
def test_python_keywords_present(app):
"""Test that Python keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("python", [])
assert "def" in keywords
assert "class" in keywords
assert "if" in keywords
assert "for" in keywords
assert "import" in keywords
def test_javascript_keywords_present(app):
"""Test that JavaScript keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("javascript", [])
assert "function" in keywords
assert "const" in keywords
assert "let" in keywords
assert "var" in keywords
assert "class" in keywords
def test_php_keywords_present(app):
"""Test that PHP keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("php", [])
assert "function" in keywords
assert "class" in keywords
assert "echo" in keywords
assert "require" in keywords
def test_bash_keywords_present(app):
"""Test that Bash keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("bash", [])
assert "if" in keywords
assert "then" in keywords
assert "fi" in keywords
assert "for" in keywords
def test_html_keywords_present(app):
"""Test that HTML keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("html", [])
assert "div" in keywords
assert "span" in keywords
assert "body" in keywords
assert "html" in keywords
def test_css_keywords_present(app):
"""Test that CSS keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("css", [])
assert "color" in keywords
assert "background" in keywords
assert "margin" in keywords
assert "padding" in keywords
def test_all_patterns_have_string_and_number(app):
"""Test that all languages have string and number patterns."""
languages = ["python", "javascript", "php", "bash", "html", "css"]
for lang in languages:
patterns = CodeHighlighter.get_language_patterns(lang)
pattern_types = [p[1] for p in patterns]
assert "string" in pattern_types, f"{lang} should have string pattern"
assert "number" in pattern_types, f"{lang} should have number pattern"
def test_patterns_have_regex_format(app):
"""Test that patterns are in regex format."""
patterns = CodeHighlighter.get_language_patterns("python")
for pattern, pattern_type in patterns:
# Each pattern should be a string (regex pattern)
assert isinstance(pattern, str)
# Each type should be a string
assert isinstance(pattern_type, str)
def test_code_block_metadata_update_language(app):
"""Test updating language for existing block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
assert metadata.get_language(0) == "python"
metadata.set_language(0, "javascript")
assert metadata.get_language(0) == "javascript"
def test_get_format_preserves_base_format_properties(app):
"""Test that get_format_for_type preserves base format properties."""
base_format = QTextCharFormat()
base_format.setFontPointSize(14)
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
# Should be based on the base_format
assert isinstance(fmt, QTextCharFormat)

View file

@ -1,9 +1,5 @@
import pytest
import json, csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
from datetime import date, timedelta
def _today():
@ -18,10 +14,6 @@ def _tomorrow():
return (dt.date.today() + dt.timedelta(days=1)).isoformat()
def _days_ago(n):
return (date.today() - timedelta(days=n)).isoformat()
def _entry(text, i=0):
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
@ -65,7 +57,7 @@ def test_dates_with_content_and_search(fresh_db):
assert any(d == _tomorrow() for d, _ in hits)
def test_get_all_entries_and_export(fresh_db, tmp_path):
def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path):
for i in range(3):
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
@ -80,6 +72,10 @@ def test_get_all_entries_and_export(fresh_db, tmp_path):
fresh_db.export_csv(entries, str(csv_path))
assert csv_path.exists() and list(csv.reader(open(csv_path)))
txt_path = tmp_path / "export.txt"
fresh_db.export_txt(entries, str(txt_path))
assert txt_path.exists() and txt_path.read_text().strip()
md_path = tmp_path / "export.md"
fresh_db.export_markdown(entries, str(md_path))
md_text = md_path.read_text()
@ -97,12 +93,19 @@ def test_get_all_entries_and_export(fresh_db, tmp_path):
fresh_db.export_sqlcipher(str(sqlc_path))
assert sqlc_path.exists() and sqlc_path.read_bytes()
for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]:
path.unlink(missing_ok=True)
fresh_db.export_by_extension(str(path))
assert path.exists()
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
fresh_db.rekey("new-key-123")
fresh_db.close()
from bouquin.db import DBManager
tmp_db_cfg.key = "new-key-123"
db2 = DBManager(tmp_db_cfg)
assert db2.connect()
@ -115,490 +118,10 @@ def test_compact_and_close_dont_crash(fresh_db):
fresh_db.close()
def test_connect_integrity_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
# simulate cursor() ok, but integrity check raising
called = {"ok": False}
def bad_integrity(self):
called["ok"] = True
raise sqlite.Error("bad cipher")
monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True)
ok = db.connect()
assert not ok and called["ok"]
assert db.conn is None
import pytest
def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Monkeypatch connect() on the instance so the reconnect attempt fails
def fail_connect():
return False
monkeypatch.setattr(db, "connect", fail_connect, raising=False)
with pytest.raises(sqlite.Error):
db.rekey("newkey")
def test_revert_wrong_date_raises(fresh_db):
d1, d2 = "2024-01-01", "2024-01-02"
v1_id, _ = fresh_db.save_new_version(d1, "one", "seed")
fresh_db.save_new_version(d2, "two", "seed")
def test_export_by_extension_unsupported(fresh_db, tmp_path):
p = tmp_path / "export.xyz"
with pytest.raises(ValueError):
fresh_db.revert_to_version(d2, version_id=v1_id)
def test_compact_error_path(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Replace cursor.execute to raise to hit except branch
class BadCur:
def execute(self, *a, **k):
raise RuntimeError("boom")
class BadConn:
def cursor(self):
return BadCur()
db.conn = BadConn()
# Should not raise; just print error
db.compact()
class _Cur:
def __init__(self, rows):
self._rows = rows
def execute(self, *a, **k):
return self
def fetchall(self):
return list(self._rows)
class _Conn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _Cur(self._rows)
def test_integrity_check_raises_with_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems with text details
db.conn = _Conn([("bad page checksum",), (None,)])
with pytest.raises(sqlite.IntegrityError) as ei:
db._integrity_ok()
# Message should contain the detail string
assert "bad page checksum" in str(ei.value)
def test_integrity_check_raises_without_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems but without textual details
db.conn = _Conn([(None,), (None,)])
with pytest.raises(sqlite.IntegrityError):
db._integrity_ok()
# ============================================================================
# DB _strip_markdown and _count_words Tests
# ============================================================================
def test_db_strip_markdown_empty_text(fresh_db):
"""Test strip_markdown with empty text."""
result = fresh_db._strip_markdown("")
assert result == ""
def test_db_strip_markdown_none_text(fresh_db):
"""Test strip_markdown with None."""
result = fresh_db._strip_markdown(None)
assert result == ""
def test_db_strip_markdown_fenced_code_blocks(fresh_db):
"""Test stripping fenced code blocks."""
text = """
Some text here
```python
def hello():
print("world")
```
More text after
"""
result = fresh_db._strip_markdown(text)
assert "def hello" not in result
assert "Some text" in result
assert "More text" in result
def test_db_strip_markdown_inline_code(fresh_db):
"""Test stripping inline code."""
text = "Here is some `inline code` in text"
result = fresh_db._strip_markdown(text)
assert "`" not in result
assert "inline code" not in result
assert "Here is some" in result
assert "in text" in result
def test_db_strip_markdown_links(fresh_db):
"""Test converting markdown links to plain text."""
text = "Check out [this link](https://example.com) for more info"
result = fresh_db._strip_markdown(text)
assert "this link" in result
assert "https://example.com" not in result
assert "[" not in result
assert "]" not in result
def test_db_strip_markdown_emphasis_and_headers(fresh_db):
"""Test stripping emphasis markers and headers."""
text = """
# Header 1
## Header 2
**bold text** and *italic text*
> blockquote
_underline_
"""
result = fresh_db._strip_markdown(text)
assert "#" not in result
assert "*" not in result
assert "_" not in result
assert ">" not in result
assert "bold text" in result
assert "italic text" in result
def test_db_strip_markdown_html_tags(fresh_db):
"""Test stripping HTML tags."""
text = "Some <b>bold</b> and <i>italic</i> text with <div>divs</div>"
result = fresh_db._strip_markdown(text)
# The regex replaces tags with spaces, may leave some angle brackets from malformed HTML
# The important thing is that the words are preserved
assert "bold" in result
assert "italic" in result
assert "divs" in result
def test_db_strip_markdown_complex_document(fresh_db):
"""Test stripping complex markdown document."""
text = """
# My Document
This is a paragraph with **bold** and *italic* text.
```javascript
const x = 10;
console.log(x);
```
Here's a [link](https://example.com) and some `code`.
> A blockquote
<p>HTML paragraph</p>
"""
result = fresh_db._strip_markdown(text)
assert "My Document" in result
assert "paragraph" in result
assert "const x" not in result
assert "https://example.com" not in result
assert "<p>" not in result
def test_db_count_words_simple(fresh_db):
"""Test word counting on simple text."""
text = "This is a simple test with seven words"
count = fresh_db._count_words(text)
assert count == 8
def test_db_count_words_empty(fresh_db):
"""Test word counting on empty text."""
count = fresh_db._count_words("")
assert count == 0
def test_db_count_words_with_markdown(fresh_db):
"""Test word counting strips markdown first."""
text = "**Bold** and *italic* and `code` words"
count = fresh_db._count_words(text)
# Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
assert count == 5
def test_db_count_words_with_unicode(fresh_db):
"""Test word counting with unicode characters."""
text = "Hello 世界 café naïve résumé"
count = fresh_db._count_words(text)
# Should count all words including unicode
assert count >= 5
def test_db_count_words_with_numbers(fresh_db):
"""Test word counting includes numbers."""
text = "There are 123 apples and 456 oranges"
count = fresh_db._count_words(text)
assert count == 7
def test_db_count_words_with_punctuation(fresh_db):
"""Test word counting handles punctuation correctly."""
text = "Hello, world! How are you? I'm fine, thanks."
count = fresh_db._count_words(text)
# Hello, world, How, are, you, I, m, fine, thanks = 9 words
assert count == 9
# ============================================================================
# DB gather_stats Tests
# ============================================================================
def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database."""
stats = fresh_db.gather_stats()
assert len(stats) == 10
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
page_most_tags,
page_most_tags_count,
revisions_by_date,
) = stats
assert pages_with_content == 0
assert total_revisions == 0
assert page_most_revisions is None
assert page_most_revisions_count == 0
assert len(words_by_date) == 0
assert total_words == 0
assert unique_tags == 0
assert page_most_tags is None
assert page_most_tags_count == 0
assert len(revisions_by_date) == 0
def test_db_gather_stats_with_content(fresh_db):
"""Test gather_stats with actual content."""
# Add multiple pages with different content
fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
fresh_db.save_new_version(
"2024-01-01", "Hello world this is version two", "v2"
) # 2nd revision
fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
stats = fresh_db.gather_stats()
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
page_most_tags,
page_most_tags_count,
revisions_by_date,
) = stats
assert pages_with_content == 2
assert total_revisions == 3
assert page_most_revisions == "2024-01-01"
assert page_most_revisions_count == 2
assert total_words > 0
assert len(words_by_date) == 2
def test_db_gather_stats_word_counting(fresh_db):
"""Test that gather_stats counts words correctly."""
# Add page with known word count
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
assert total_words == 5
test_date = date(2024, 1, 1)
assert test_date in words_by_date
assert words_by_date[test_date] == 5
def test_db_gather_stats_with_tags(fresh_db):
"""Test gather_stats with tags."""
# Add tags
fresh_db.add_tag("tag1", "#ff0000")
fresh_db.add_tag("tag2", "#00ff00")
fresh_db.add_tag("tag3", "#0000ff")
# Add pages with tags
fresh_db.save_new_version("2024-01-01", "Page 1", "test")
fresh_db.save_new_version("2024-01-02", "Page 2", "test")
fresh_db.set_tags_for_page(
"2024-01-01", ["tag1", "tag2", "tag3"]
) # Page 1 has 3 tags
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
assert unique_tags == 3
assert page_most_tags == "2024-01-01"
assert page_most_tags_count == 3
def test_db_gather_stats_revisions_by_date(fresh_db):
"""Test revisions_by_date tracking."""
# Add multiple revisions on different dates
fresh_db.save_new_version("2024-01-01", "First", "v1")
fresh_db.save_new_version("2024-01-01", "Second", "v2")
fresh_db.save_new_version("2024-01-01", "Third", "v3")
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3
assert date(2024, 1, 2) in revisions_by_date
assert revisions_by_date[date(2024, 1, 2)] == 1
def test_db_gather_stats_handles_malformed_dates(fresh_db):
"""Test that gather_stats handles malformed dates gracefully."""
# This is hard to test directly since the DB enforces date format
# But we can test that normal dates work
fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
# Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date
def test_db_gather_stats_current_version_only(fresh_db):
"""Test that word counts use current version only, not all revisions."""
# Add multiple revisions
fresh_db.save_new_version("2024-01-01", "one two three", "v1")
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
# Should count words from current version (5 words), not old version
assert total_words == 5
assert words_by_date[date(2024, 1, 1)] == 5
def test_db_gather_stats_no_tags(fresh_db):
"""Test gather_stats when there are no tags."""
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
assert unique_tags == 0
assert page_most_tags is None
assert page_most_tags_count == 0
def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
"""Test that gather_stats handles exception in dates_with_content."""
def bad_dates():
raise RuntimeError("Simulated error")
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
# Should still return stats without crashing
stats = fresh_db.gather_stats()
pages_with_content = stats[0]
# Should default to 0 when exception occurs
assert pages_with_content == 0
def test_delete_version(fresh_db):
"""Test deleting a specific version by version_id."""
d = date.today().isoformat()
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
# Verify all versions exist
versions = fresh_db.list_versions(d)
assert len(versions) == 3
# Delete the second version
fresh_db.delete_version(version_id=vid2)
# Verify it's deleted
versions_after = fresh_db.list_versions(d)
assert len(versions_after) == 2
# Make sure the deleted version is not in the list
version_ids = [v["id"] for v in versions_after]
assert vid2 not in version_ids
assert vid1 in version_ids
assert vid3 in version_ids
def test_update_reminder_active(fresh_db):
"""Test updating the active status of a reminder."""
from bouquin.reminders import Reminder, ReminderType
# Create a reminder object
reminder = Reminder(
id=None,
text="Test reminder",
reminder_type=ReminderType.ONCE,
time_str="14:30",
date_iso=date.today().isoformat(),
active=True,
)
# Save it
reminder_id = fresh_db.save_reminder(reminder)
# Verify it's active
reminders = fresh_db.get_all_reminders()
active_reminder = [r for r in reminders if r.id == reminder_id][0]
assert active_reminder.active is True
# Deactivate it
fresh_db.update_reminder_active(reminder_id, False)
# Verify it's inactive
reminders = fresh_db.get_all_reminders()
inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
assert inactive_reminder.active is False
# Reactivate it
fresh_db.update_reminder_active(reminder_id, True)
# Verify it's active again
reminders = fresh_db.get_all_reminders()
reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
assert reactivated_reminder.active is True
fresh_db.export_by_extension(str(p))

View file

@ -1,10 +1,8 @@
import pytest
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar
@pytest.fixture
@ -16,6 +14,10 @@ def editor(app, qtbot):
return ed
from bouquin.find_bar import FindBar
@pytest.mark.gui
def test_findbar_basic_navigation(qtbot, editor):
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start)
@ -40,6 +42,7 @@ def test_findbar_basic_navigation(qtbot, editor):
def test_show_bar_seeds_selection(qtbot, editor):
from PySide6.QtGui import QTextCursor
editor.from_markdown("alpha beta")
c = editor.textCursor()
@ -52,120 +55,3 @@ def test_show_bar_seeds_selection(qtbot, editor):
fb.show_bar()
assert fb.edit.text().lower() == "alpha"
fb.hide_bar()
def test_show_bar_no_editor(qtbot, app):
fb = FindBar(lambda: None)
qtbot.addWidget(fb)
fb.show_bar() # should early return without crashing and not become visible
assert not fb.isVisible()
def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
editor.from_markdown("alpha\n\nbeta")
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
# Select across the paragraph separator U+2029 equivalent select more than one block
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.show_bar()
assert fb.edit.text() == "" # should not seed with multi-paragraph
fb.hide_bar()
def test_find_wraps_and_bumps_caret(qtbot, editor):
editor.from_markdown("alpha alpha alpha")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("alpha")
# Select the first occurrence so caret bumping path triggers
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb.find_next() # should bump to after current selection then find next
sel = editor.textCursor().selectedText()
assert sel.lower() == "alpha"
# Force wrap to start by moving cursor to end then searching next
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
fb.find_next() # triggers wrap-to-start path
assert editor.textCursor().hasSelection()
def test_update_highlight_clear_when_empty(qtbot, editor):
editor.from_markdown("find me find me")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("find")
fb._update_highlight()
assert editor.extraSelections() # some highlights present
fb.edit.setText("")
fb._update_highlight() # should clear
assert not editor.extraSelections()
def test_maybe_hide_and_wrap_prev(qtbot, editor):
editor.setPlainText("a a a")
fb = FindBar(editor=editor, shortcut_parent=editor)
qtbot.addWidget(editor)
qtbot.addWidget(fb)
editor.show()
fb.show()
fb.edit.setText("a")
fb._update_highlight()
assert fb.isVisible()
fb._maybe_hide()
assert not fb.isVisible()
fb.show_bar()
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
editor.setTextCursor(c)
fb.find_prev()
def _make_fb(editor, qtbot):
"""Create a FindBar with a live parent kept until teardown."""
parent = QWidget()
qtbot.addWidget(parent)
fb = FindBar(editor=editor, parent=parent)
qtbot.addWidget(fb)
parent.show()
fb.show()
return fb, parent
def test_find_next_early_returns_no_editor(qtbot):
# No editor: should early return and not crash
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.find_next()
def test_find_next_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_next()
def test_find_prev_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_prev()
def test_update_highlight_early_returns_no_editor(qtbot):
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.edit.setText("abc")
fb._update_highlight() # should return without error

View file

@ -1,5 +1,5 @@
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import Qt
from bouquin.history_dialog import HistoryDialog
@ -17,295 +17,3 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db):
dlg.list.setCurrentRow(1)
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
assert fresh_db.get_entry(d) == "v1"
def test_history_dialog_no_selection_clears(qtbot, fresh_db):
d = "2001-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Clear selection (no current item) and call slot
dlg.list.setCurrentItem(None)
dlg._on_select()
assert dlg.preview.toPlainText() == ""
assert dlg.diff.toPlainText() == ""
assert not dlg.btn_revert.isEnabled()
def test_history_dialog_revert_same_version_noop(qtbot, fresh_db):
d = "2001-01-01"
# Only one version; that's the current
vid, _ = fresh_db.save_new_version(d, "seed", "note")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Pick the only item (current)
dlg.list.setCurrentRow(0)
# Clicking revert should simply return (no change)
before = fresh_db.get_entry(d)
dlg._revert()
after = fresh_db.get_entry(d)
assert before == after
def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
d = "2001-01-02"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the row
dlg.list.setCurrentRow(0)
# Monkeypatch db to raise inside revert_to_version to hit except path
def boom(date_iso, version_id):
raise RuntimeError("nope")
dlg._db.revert_to_version = boom
# Auto-accept any QMessageBox that appears
def _pump():
for m in QMessageBox.instances():
m.accept()
t = QTimer()
t.setInterval(10)
t.timeout.connect(_pump)
t.start()
try:
dlg._revert()
finally:
t.stop()
def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
d = "2000-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# No selection at all -> early return
dlg.list.clearSelection()
dlg._revert() # should not raise
def test_revert_returns_when_current_selected(qtbot, fresh_db):
d = "2000-01-02"
fresh_db.save_new_version(d, "v1", "first")
# Create a second version so there is a 'current'
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the current item -> early return
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) == dlg._current_id:
dlg.list.setCurrentItem(item)
break
dlg._revert() # no-op
def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
"""
Trigger the exception path in _revert() and auto-accept the modal
QMessageBox that HistoryDialog pops so the test doesn't hang.
"""
d = "2000-01-03"
fresh_db.save_new_version(d, "v1", "first")
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select a non-current item
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Make revert raise to hit the except/critical message path.
def boom(*_a, **_k):
raise RuntimeError("nope")
monkeypatch.setattr(dlg._db, "revert_to_version", boom)
# Prepare a small helper that keeps trying to close an active modal box,
# but gives up after a bounded number of attempts.
def make_closer(max_tries=50, interval_ms=10):
tries = {"n": 0}
def closer():
tries["n"] += 1
w = QApplication.activeModalWidget()
if isinstance(w, QMessageBox):
# Prefer clicking the OK button if present; otherwise accept().
ok = w.button(QMessageBox.Ok)
if ok is not None:
ok.click()
else:
w.accept()
elif tries["n"] < max_tries:
QTimer.singleShot(interval_ms, closer)
return closer
# Schedule auto-close right before we trigger the modal dialog.
QTimer.singleShot(0, make_closer())
# Should show the critical box, which our timer will accept; _revert returns.
dlg._revert()
def test_delete_version_from_history(qtbot, fresh_db):
"""Test deleting a version through the history dialog."""
d = "2001-01-01"
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Verify we have 3 versions
assert dlg.list.count() == 3
# Select the first version (oldest, not current)
dlg.list.setCurrentRow(2) # Last row is oldest version
# Call _delete
dlg._delete()
# Verify the version was deleted
assert dlg.list.count() == 2
# Verify from DB
versions = fresh_db.list_versions(d)
assert len(versions) == 2
def test_delete_current_version_returns_early(qtbot, fresh_db):
"""Test that deleting the current version returns early without deleting."""
d = "2001-01-02"
# Create versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Find and select the current version
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) == dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Try to delete - should return early
dlg._delete()
# Verify nothing was deleted
versions = fresh_db.list_versions(d)
assert len(versions) == 2
def test_delete_version_with_error(qtbot, fresh_db, monkeypatch):
"""Test that delete version error shows a message box."""
d = "2001-01-03"
# Create versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select a non-current version
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Make delete_version raise an error
def boom(*args, **kwargs):
raise RuntimeError("Delete failed")
monkeypatch.setattr(dlg._db, "delete_version", boom)
# Set up auto-closer for message box
def make_closer(max_tries=50, interval_ms=10):
tries = {"n": 0}
def closer():
tries["n"] += 1
w = QApplication.activeModalWidget()
if isinstance(w, QMessageBox):
ok = w.button(QMessageBox.Ok)
if ok is not None:
ok.click()
else:
w.accept()
elif tries["n"] < max_tries:
QTimer.singleShot(interval_ms, closer)
return closer
QTimer.singleShot(0, make_closer())
# Call delete - should show error message
dlg._delete()
def test_delete_multiple_versions(qtbot, fresh_db):
"""Test deleting multiple versions at once."""
d = "2001-01-04"
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
vid4, _ = fresh_db.save_new_version(d, "v4", "fourth")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select multiple non-current items
selected_count = 0
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
item.setSelected(True)
selected_count += 1
if selected_count >= 2: # Select 2 items
break
# Delete them
dlg._delete()
# Verify versions were deleted (should have current + 1 remaining)
versions = fresh_db.list_versions(d)
assert len(versions) == 2 # Current + 1 that wasn't deleted

View file

@ -1,205 +1,9 @@
from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog, QLineEdit
def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt()
qtbot.addWidget(kp)
kp.show()
kp.key_entry.setText("swordfish")
kp.edit.setText("swordfish")
assert kp.key() == "swordfish"
def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
"""Test KeyPrompt with DB path selection - covers lines 57-67"""
test_db = tmp_path / "test.db"
test_db.touch()
# Create prompt with show_db_change=True
prompt = KeyPrompt(show_db_change=True)
qtbot.addWidget(prompt)
# Mock the file dialog to return a file
def mock_get_open_filename(*args, **kwargs):
return str(test_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Simulate clicking the browse button
# Find the browse button by looking through the widget's children
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Verify the path was set
assert prompt.path_edit is not None
assert str(test_db) in prompt.path_edit.text()
def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
"""Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
# Create prompt with show_db_change=True
prompt = KeyPrompt(show_db_change=True)
qtbot.addWidget(prompt)
# Mock the file dialog to return empty string (user cancelled)
def mock_get_open_filename(*args, **kwargs):
return "", ""
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Store original path text
original_text = prompt.path_edit.text() if prompt.path_edit else ""
# Simulate clicking the browse button
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Path should not have changed since no file was selected
if prompt.path_edit:
assert prompt.path_edit.text() == original_text
def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
"""Test KeyPrompt with existing DB path provided"""
test_db = tmp_path / "existing.db"
test_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
qtbot.addWidget(prompt)
# Verify the path is pre-filled
assert prompt.path_edit is not None
assert str(test_db) in prompt.path_edit.text()
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
"""Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt)
# Path edit should exist but be empty
assert prompt.path_edit is not None
assert prompt.path_edit.text() == ""
def test_key_prompt_accept_with_valid_key(qtbot, app):
"""Test accepting prompt with valid key"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
# Enter a key
prompt.key_entry.setText("test-key-123")
# Accept
QTimer.singleShot(0, prompt.accept)
qtbot.wait(50)
assert prompt.key_entry.text() == "test-key-123"
def test_key_prompt_without_db_change(qtbot, app):
"""Test KeyPrompt without show_db_change"""
prompt = KeyPrompt(show_db_change=False)
qtbot.addWidget(prompt)
# Path edit should not exist
assert prompt.path_edit is None
def test_key_prompt_password_visibility(qtbot, app):
"""Test password entry mode"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
# Initially should be password mode
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
# Enter some text
prompt.key_entry.setText("secret")
# The text should be obscured
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
def test_key_prompt_key_method(qtbot, app):
"""Test the key() method returns entered text"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
prompt.key_entry.setText("my-secret-key")
assert prompt.key() == "my-secret-key"
def test_key_prompt_db_path_method(qtbot, app, tmp_path):
"""Test the db_path() method returns selected path"""
test_db = tmp_path / "test.db"
test_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
qtbot.addWidget(prompt)
# Should return the db_path
assert prompt.db_path() == test_db
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
"""Test browsing when initial_db_path is set - covers line 57 with non-None path"""
initial_db = tmp_path / "initial.db"
initial_db.touch()
new_db = tmp_path / "new.db"
new_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
qtbot.addWidget(prompt)
# Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs):
# Verify that start_dir was passed correctly (line 57)
return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Find and click browse button
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Verify new path was set
assert str(new_db) in prompt.path_edit.text()
assert prompt.db_path() == new_db

View file

@ -1,15 +1,16 @@
import pytest
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot):
@pytest.mark.gui
def test_lock_overlay_reacts_to_theme(qtbot):
host = QWidget()
qtbot.addWidget(host)
host.show()
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ol = LockOverlay(host, on_unlock=lambda: None, themes=themes)
ol = LockOverlay(host, on_unlock=lambda: None)
qtbot.addWidget(ol)
ol.show()

View file

@ -1,6 +1,4 @@
import importlib
import runpy
import pytest
def test_main_module_has_main():
@ -11,87 +9,3 @@ def test_main_module_has_main():
def test_dunder_main_imports_main():
m = importlib.import_module("bouquin.__main__")
assert hasattr(m, "main")
def test_dunder_main_calls_main(monkeypatch):
called = {"ok": False}
def fake_main():
called["ok"] = True
# Replace real main with a stub to avoid launching Qt event loop
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
# Ensure that when __main__ imports from .main it gets our stub
import bouquin.main as real_main
monkeypatch.setattr(real_main, "main", fake_main, raising=True)
# Execute the module as a script
runpy.run_module("bouquin.__main__", run_name="__main__")
assert called["ok"]
def test_main_creates_and_shows(monkeypatch):
# Create a fake QApplication with the minimal API
class FakeApp:
def __init__(self, argv):
self.ok = True
def setApplicationName(self, *_):
pass
def setOrganizationName(self, *_):
pass
def setWindowIcon(self, *_):
pass
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.shown = False
def show(self):
self.shown = True
class FakeSettings:
def value(self, k, default=None):
return "light" if k == "ui/theme" else default
# Patch imports inside bouquin.main
import bouquin.main as m
monkeypatch.setattr(m, "QApplication", FakeApp, raising=True)
monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True)
# Theme classes
class FakeTM:
def __init__(self, app, cfg):
pass
def apply(self, theme):
pass
class FakeTheme:
def __init__(self, s):
pass
class FakeCfg:
def __init__(self, theme):
self.theme = theme
monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True)
monkeypatch.setattr(m, "Theme", FakeTheme, raising=True)
monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True)
# get_settings() used inside main()
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True)
# Run
with pytest.raises(SystemExit) as e:
m.main()
assert e.value.code == 0

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,354 +0,0 @@
from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
def test_pomodoro_timer_init(qtbot, app, fresh_db):
"""Test PomodoroTimer initialization."""
task_text = "Write unit tests"
timer = PomodoroTimer(task_text)
qtbot.addWidget(timer)
assert timer._task_text == task_text
assert timer._elapsed_seconds == 0
assert timer._running is False
assert timer.time_label.text() == "00:00:00"
assert timer.stop_btn.isEnabled() is False
def test_pomodoro_timer_start(qtbot, app):
"""Test starting the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._toggle_timer()
assert timer._running is True
assert timer.stop_btn.isEnabled() is True
def test_pomodoro_timer_pause(qtbot, app):
"""Test pausing the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
assert timer._running is True
# Pause the timer
timer._toggle_timer()
assert timer._running is False
def test_pomodoro_timer_resume(qtbot, app):
"""Test resuming the timer after pause."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start, pause, then resume
timer._toggle_timer() # Start
timer._toggle_timer() # Pause
timer._toggle_timer() # Resume
assert timer._running is True
def test_pomodoro_timer_tick(qtbot, app):
"""Test timer tick increments elapsed time."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
initial_time = timer._elapsed_seconds
timer._tick()
assert timer._elapsed_seconds == initial_time + 1
def test_pomodoro_timer_display_update(qtbot, app):
"""Test display updates with various elapsed times."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Test 0 seconds
timer._elapsed_seconds = 0
timer._update_display()
assert timer.time_label.text() == "00:00:00"
# Test 65 seconds (1 min 5 sec)
timer._elapsed_seconds = 65
timer._update_display()
assert timer.time_label.text() == "00:01:05"
# Test 3665 seconds (1 hour 1 min 5 sec)
timer._elapsed_seconds = 3665
timer._update_display()
assert timer.time_label.text() == "01:01:05"
# Test 3600 seconds (1 hour exactly)
timer._elapsed_seconds = 3600
timer._update_display()
assert timer.time_label.text() == "01:00:00"
def test_pomodoro_timer_stop_and_log_running(qtbot, app):
"""Test stopping the timer while it's running."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
timer._elapsed_seconds = 100
# Connect a mock to the signal
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 100 # elapsed seconds
assert signal_received[0][1] == "Test task"
def test_pomodoro_timer_stop_and_log_paused(qtbot, app):
"""Test stopping the timer when it's paused."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._elapsed_seconds = 50
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert len(signal_received) == 1
assert signal_received[0][0] == 50
def test_pomodoro_timer_multiple_ticks(qtbot, app):
"""Test multiple timer ticks."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
for i in range(10):
timer._tick()
assert timer._elapsed_seconds == 10
assert "00:00:10" in timer.time_label.text()
def test_pomodoro_timer_modal_state(qtbot, app):
"""Test that timer is non-modal."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
assert timer.isModal() is False
def test_pomodoro_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._db is fresh_db
assert manager._parent is parent
assert manager._active_timer is None
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
line_text = "Important task"
date_iso = "2024-01-15"
manager.start_timer_for_line(line_text, date_iso)
assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text
qtbot.addWidget(manager._active_timer)
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes the previous one."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
# Start first timer
manager.start_timer_for_line("Task 1", "2024-01-15")
first_timer = manager._active_timer
qtbot.addWidget(first_timer)
first_timer.show()
# Start second timer
manager.start_timer_for_line("Task 2", "2024-01-16")
second_timer = manager._active_timer
qtbot.addWidget(second_timer)
assert first_timer is not second_timer
assert second_timer._task_text == "Task 2"
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped with very short time logs minimum hours."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid actually showing it
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(10, "Quick task", "2024-01-15")
# Should set minimum of 0.25 hours
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
assert hours_set >= 0.25
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Test that elapsed time is properly rounded to decimal hours."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# Test with 1800 seconds (30 minutes)
manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped pre-fills the note in time log dialog."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
task_text = "Write documentation"
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(3600, task_text, "2024-01-15")
mock_dialog.note.setText.assert_called_once_with(task_text)
def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped signal is properly connected."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager.start_timer_for_line("Task", "2024-01-15")
timer = manager._active_timer
qtbot.addWidget(timer)
# Simulate timer stopped
timer._elapsed_seconds = 1000
timer._stop_and_log()
# TimeLogDialog should have been created
assert mock_dialog.exec.called
def test_pomodoro_timer_accepts_parent(qtbot, app):
"""Test that timer accepts a parent widget."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
timer = PomodoroTimer("Task", parent)
qtbot.addWidget(timer)
assert timer.parent() is parent
def test_pomodoro_manager_no_active_timer_initially(app, fresh_db):
"""Test that manager starts with no active timer."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._active_timer is None
def test_pomodoro_timer_start_stop_cycle(qtbot, app):
"""Test a complete start-stop cycle."""
timer = PomodoroTimer("Complete cycle")
qtbot.addWidget(timer)
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
# Start
timer._toggle_timer()
assert timer._running is True
# Simulate some ticks
for _ in range(5):
timer._tick()
# Stop
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 5
def test_pomodoro_timer_long_elapsed_time(qtbot, app):
"""Test display with very long elapsed time."""
timer = PomodoroTimer("Long task")
qtbot.addWidget(timer)
# Set to 2 hours, 34 minutes, 56 seconds
timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56
timer._update_display()
assert timer.time_label.text() == "02:34:56"

View file

@ -1,801 +0,0 @@
from unittest.mock import patch
from bouquin.reminders import (
Reminder,
ReminderType,
ReminderDialog,
UpcomingRemindersWidget,
ManageRemindersDialog,
)
from PySide6.QtCore import QDate, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
from datetime import date, timedelta
def test_reminder_type_enum(app):
"""Test ReminderType enum values."""
assert ReminderType.ONCE is not None
assert ReminderType.DAILY is not None
assert ReminderType.WEEKDAYS is not None
assert ReminderType.WEEKLY is not None
def test_reminder_dataclass_creation(app):
"""Test creating a Reminder instance."""
reminder = Reminder(
id=1,
text="Test reminder",
time_str="10:30",
reminder_type=ReminderType.DAILY,
weekday=None,
active=True,
date_iso=None,
)
assert reminder.id == 1
assert reminder.text == "Test reminder"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.DAILY
assert reminder.active is True
def test_reminder_dialog_init_new(qtbot, app, fresh_db):
"""Test ReminderDialog initialization for new reminder."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog._reminder is None
assert dialog.text_edit.text() == ""
def test_reminder_dialog_init_existing(qtbot, app, fresh_db):
"""Test ReminderDialog initialization with existing reminder."""
reminder = Reminder(
id=1,
text="Existing reminder",
time_str="14:30",
reminder_type=ReminderType.WEEKLY,
weekday=2,
active=True,
)
dialog = ReminderDialog(fresh_db, reminder=reminder)
qtbot.addWidget(dialog)
assert dialog.text_edit.text() == "Existing reminder"
assert dialog.time_edit.time().hour() == 14
assert dialog.time_edit.time().minute() == 30
def test_reminder_dialog_type_changed(qtbot, app, fresh_db):
"""Test that weekday combo visibility changes with type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show() # Show the dialog so child widgets can be visible
# Find weekly type in combo
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is True
# Switch to daily
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.DAILY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is False
def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db):
"""Test getting reminder with ONCE type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test task")
dialog.time_edit.setTime(QTime(10, 30))
# Set to ONCE type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.ONCE:
dialog.type_combo.setCurrentIndex(i)
break
reminder = dialog.get_reminder()
assert reminder.text == "Test task"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.ONCE
assert reminder.date_iso is not None
def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db):
"""Test getting reminder with WEEKLY type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Weekly meeting")
dialog.time_edit.setTime(QTime(15, 0))
# Set to WEEKLY type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
dialog.weekday_combo.setCurrentIndex(1) # Tuesday
reminder = dialog.get_reminder()
assert reminder.text == "Weekly meeting"
assert reminder.reminder_type == ReminderType.WEEKLY
assert reminder.weekday == 1
def test_upcoming_reminders_widget_init(qtbot, app, fresh_db):
"""Test UpcomingRemindersWidget initialization."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
assert widget._db is fresh_db
assert widget.body.isVisible() is False
def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db):
"""Test toggling reminder list visibility."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.show() # Show the widget so child widgets can be visible
# Initially hidden
assert widget.body.isVisible() is False
# Click toggle
widget.toggle_btn.click()
qtbot.wait(10) # Wait for Qt event processing
assert widget.body.isVisible() is True
def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db):
"""Test should_fire_on_date for ONCE type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.ONCE,
date_iso="2024-01-15",
)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False
def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db):
"""Test should_fire_on_date for DAILY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
# Should fire every day
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True
def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKDAYS type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKDAYS,
)
# Monday (dayOfWeek = 1)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
# Friday (dayOfWeek = 5)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True
# Saturday (dayOfWeek = 6)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False
# Sunday (dayOfWeek = 7)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False
def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKLY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Fire on Wednesday (weekday = 2)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2,
)
# Wednesday (dayOfWeek = 3, so weekday = 2)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True
# Thursday (dayOfWeek = 4, so weekday = 3)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False
def test_upcoming_reminders_widget_refresh_no_db(qtbot, app):
"""Test refresh with no database connection."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget.refresh()
def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db):
"""Test refresh displays reminders."""
# Add a reminder to the database
reminder = Reminder(
id=None,
text="Test reminder",
time_str="23:59", # Late time so it's in the future
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should have at least one item (or "No upcoming reminders")
assert widget.reminder_list.count() > 0
def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the widget."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
widget._add_reminder()
# Reminder should be saved
reminders = fresh_db.get_all_reminders()
assert len(reminders) > 0
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
"""Test editing a reminder through the widget."""
# Add a reminder first
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Get the list item
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
mock_get.return_value = updated
widget._edit_reminder(item)
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
"""Test deleting a single selected reminder."""
# Add a reminder
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
if widget.reminder_list.count() > 0:
widget.reminder_list.setCurrentRow(0)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db):
"""Test deleting multiple selected reminders."""
# Add multiple reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Select all items
for i in range(widget.reminder_list.count()):
widget.reminder_list.item(i).setSelected(True)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
"""Test check_reminders with no database."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget._check_reminders()
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog.table is not None
def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db):
"""Test loading reminders into the table."""
# Add some reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.table.rowCount() == 3
def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app):
"""Test loading reminders with no database."""
dialog = ManageRemindersDialog(None)
qtbot.addWidget(dialog)
# Should not crash
dialog._load_reminders()
def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the manage dialog."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
dialog._add_reminder()
# Table should have one more row
assert dialog.table.rowCount() == initial_count + 1
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
"""Test editing a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
dialog._edit_reminder(reminder)
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
"""Test deleting a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_delete = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
dialog._delete_reminder(reminder_to_delete)
# Table should have one fewer row
assert dialog.table.rowCount() == initial_count - 1
def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db):
"""Test declining to delete a reminder."""
reminder = Reminder(
id=None,
text="Keep me",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_keep = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
dialog._delete_reminder(reminder_to_keep)
# Table should have same number of rows
assert dialog.table.rowCount() == initial_count
def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
"""Test that weekly reminders display the day name."""
reminder = Reminder(
id=None,
text="Weekly",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2, # Wednesday
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
# Check that the type column shows the day
type_item = dialog.table.item(0, 2)
assert "Wed" in type_item.text()
def test_reminder_dialog_accept(qtbot, app, fresh_db):
"""Test accepting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test")
dialog.accept()
def test_reminder_dialog_reject(qtbot, app, fresh_db):
"""Test rejecting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.reject()
def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db):
"""Test that reminderTriggered signal is emitted."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
signal_received = []
widget.reminderTriggered.connect(lambda text: signal_received.append(text))
# Manually emit for testing
widget.reminderTriggered.emit("Test reminder")
assert len(signal_received) == 1
assert signal_received[0] == "Test reminder"
def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db):
"""Test that 'No upcoming reminders' message is shown when appropriate."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should show message when no reminders
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
if "No upcoming" in item.text():
assert True
def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
"""Test clicking the manage button."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ManageRemindersDialog, "exec"):
widget._manage_reminders()
def test_reminder_dialog_time_format(qtbot, app, fresh_db):
"""Test that time is formatted correctly."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.time_edit.setTime(QTime(9, 5))
reminder = dialog.get_reminder()
assert reminder.time_str == "09:05"
def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db):
"""Test that past reminders are not shown in upcoming list."""
# Create a reminder that's in the past
reminder = Reminder(
id=None,
text="Past reminder",
time_str="00:01", # Very early morning
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Current time should be past 00:01
from PySide6.QtCore import QTime
if QTime.currentTime().hour() > 0:
widget.refresh()
# The past reminder for today should be filtered out
# but tomorrow's occurrence should be shown
def test_reminder_with_inactive_status(qtbot, app, fresh_db):
"""Test that inactive reminders are not displayed."""
reminder = Reminder(
id=None,
text="Inactive",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=False,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should not show inactive reminder
for i in range(widget.reminder_list.count()):
item = widget.reminder_list.item(i)
assert "Inactive" not in item.text() or "No upcoming" in item.text()
def test_reminder_triggers_and_deactivates(qtbot, fresh_db):
"""Test that ONCE reminders deactivate after firing."""
# Add a ONCE reminder for right now
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
reminder = Reminder(
id=None,
text="Test once reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=date.today().isoformat(),
active=True,
)
reminder_id = fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Verify reminder was triggered
assert len(triggered_texts) > 0
assert "Test once reminder" in triggered_texts
# Verify reminder was deactivated
reminders = fresh_db.get_all_reminders()
deactivated = [r for r in reminders if r.id == reminder_id][0]
assert deactivated.active is False
def test_reminder_not_active_skipped(qtbot, fresh_db):
"""Test that inactive reminders are not triggered."""
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
reminder = Reminder(
id=None,
text="Inactive reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=date.today().isoformat(),
active=False, # Not active
)
fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Should not trigger inactive reminder
assert len(triggered_texts) == 0
def test_reminder_not_today_skipped(qtbot, fresh_db):
"""Test that reminders not scheduled for today are skipped."""
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
# Schedule for tomorrow
tomorrow = date.today() + timedelta(days=1)
reminder = Reminder(
id=None,
text="Tomorrow's reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=tomorrow.isoformat(),
active=True,
)
fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Should not trigger tomorrow's reminder
assert len(triggered_texts) == 0
def test_reminder_context_menu_no_selection(qtbot, fresh_db):
"""Test context menu with no selection returns early."""
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Clear selection
reminders_widget.reminder_list.clearSelection()
# Show context menu - should return early
reminders_widget._show_reminder_context_menu(reminders_widget.reminder_list.pos())
def test_edit_reminder_dialog(qtbot, fresh_db):
"""Test editing a reminder through the dialog."""
reminder = Reminder(
id=None,
text="Original text",
reminder_type=ReminderType.DAILY,
time_str="14:30",
date_iso=None,
active=True,
)
fresh_db.save_reminder(reminder)
widget = QWidget()
# Create edit dialog
reminder_obj = fresh_db.get_all_reminders()[0]
dlg = ReminderDialog(fresh_db, widget, reminder=reminder_obj)
qtbot.addWidget(dlg)
# Verify fields are populated
assert dlg.text_edit.text() == "Original text"
assert dlg.time_edit.time().toString("HH:mm") == "14:30"

View file

@ -1,5 +1,4 @@
from bouquin.search import Search
from PySide6.QtWidgets import QListWidgetItem
def test_search_widget_populates_results(qtbot, fresh_db):
@ -21,80 +20,3 @@ def test_search_widget_populates_results(qtbot, fresh_db):
s.search.setText("")
qtbot.wait(50)
assert s.results.isHidden()
def test_open_selected_with_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
from PySide6.QtCore import Qt
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
s.results.addItem(it)
s._open_selected(it)
assert seen == ["1999-12-31"]
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
s = Search(fresh_db)
long = (
"This is **bold** text with alpha in the middle and some more trailing content."
)
frag = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
assert "alpha" in frag
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
def test_open_selected_ignores_no_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
# No UserRole data set -> should not emit
s._open_selected(it)
assert not seen
def test_make_html_snippet_variants(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
src = " ".join(["word"] * 200)
frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
assert frag
# Case: multiple tokens highlighted
src = "Alpha bravo charlie delta echo"
frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
assert "<b>delta</b>" in frag
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
s.search.setText("alpha")
frag, left, right = s._make_html_snippet("", "alpha", radius=10, maxlen=40)
assert frag == "" and not left and not right
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
long = "X" * 40 + "alpha" + "Y" * 40
rows = [("2000-01-01", long)]
s._populate_results("alpha", rows)
assert s.results.count() >= 1

View file

@ -0,0 +1,11 @@
from bouquin.search import Search
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
s = Search(fresh_db)
long = (
"This is **bold** text with alpha in the middle and some more trailing content."
)
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
assert "alpha" in frag
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")

View file

@ -1,4 +1,6 @@
from pathlib import Path
from bouquin.settings import (
default_db_path,
get_settings,
load_db_config,
save_db_config,
@ -6,26 +8,16 @@ from bouquin.settings import (
from bouquin.db import DBConfig
def _clear_db_settings():
s = get_settings()
for k in [
"db/default_db",
"db/path", # legacy key
"db/key",
"ui/idle_minutes",
"ui/theme",
"ui/move_todos",
"ui/tags",
"ui/time_log",
"ui/reminders",
"ui/locale",
"ui/font_size",
]:
s.remove(k)
def test_default_db_path_returns_writable_path(app, tmp_path):
p = default_db_path()
assert isinstance(p, Path)
p.parent.mkdir(parents=True, exist_ok=True)
def test_load_and_save_db_config_roundtrip(app, tmp_path):
_clear_db_settings()
s = get_settings()
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
s.remove(k)
cfg = DBConfig(
path=tmp_path / "notes.db",
@ -33,11 +25,6 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
idle_minutes=7,
theme="dark",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
)
save_db_config(cfg)
@ -47,25 +34,3 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
assert loaded.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos
assert loaded.tags == cfg.tags
assert loaded.time_log == cfg.time_log
assert loaded.reminders == cfg.reminders
assert loaded.locale == cfg.locale
assert loaded.font_size == cfg.font_size
def test_load_db_config_migrates_legacy_db_path(app, tmp_path):
_clear_db_settings()
s = get_settings()
legacy_path = tmp_path / "legacy.db"
s.setValue("db/path", str(legacy_path))
cfg = load_db_config()
# Uses the legacy value…
assert cfg.path == legacy_path
# …but also migrates to the new key and clears the old one.
assert s.value("db/default_db", "", type=str) == str(legacy_path)
assert s.value("db/path", "", type=str) == ""

View file

@ -1,14 +1,12 @@
from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt
import bouquin.settings_dialog as sd
import pytest
from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
@pytest.mark.gui
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
app = QApplication.instance()
parent = QWidget()
@ -17,12 +15,10 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
qtbot.addWidget(dlg)
dlg.show()
dlg.path_edit.setText(str(tmp_path / "alt.db"))
dlg.idle_spin.setValue(3)
dlg.theme_light.setChecked(True)
dlg.move_todos.setChecked(True)
dlg.tags.setChecked(False)
dlg.time_log.setChecked(False)
dlg.reminders.setChecked(False)
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
def _auto_accept_msgbox():
@ -36,15 +32,18 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
dlg._save()
cfg = dlg.config
assert cfg.path.name == "alt.db"
assert cfg.idle_minutes == 3
assert cfg.move_todos is True
assert cfg.tags is False
assert cfg.time_log is False
assert cfg.reminders is False
assert cfg.theme in ("light", "dark", "system")
def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox
from bouquin.key_prompt import KeyPrompt
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtWidgets import QWidget
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -60,7 +59,7 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText("supersecret")
w.edit.setText("supersecret")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
@ -82,6 +81,12 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from bouquin.key_prompt import KeyPrompt
from bouquin.db import DBManager, DBConfig
from bouquin.theme import ThemeManager, ThemeConfig, Theme
cfg = DBConfig(
path=tmp_path / "iso.db",
key="oldkey",
@ -104,7 +109,7 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
def _pump_popups():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText(keys.pop(0) if keys else "zzz")
w.edit.setText(keys.pop(0) if keys else "zzz")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
@ -124,6 +129,12 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
def test_change_key_success(qtbot, tmp_path, app):
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox
from bouquin.key_prompt import KeyPrompt
from bouquin.db import DBManager, DBConfig
from bouquin.theme import ThemeManager, ThemeConfig, Theme
cfg = DBConfig(
path=tmp_path / "iso2.db",
key="oldkey",
@ -146,7 +157,7 @@ def test_change_key_success(qtbot, tmp_path, app):
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText(keys.pop(0) if keys else "newkey")
w.edit.setText(keys.pop(0) if keys else "newkey")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
@ -167,246 +178,3 @@ def test_change_key_success(qtbot, tmp_path, app):
assert db2.connect()
assert "seed" in db2.get_entry("2001-01-01")
db2.close()
def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
# Parent with ThemeManager (dialog uses parent().themes.set(...))
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
# Monkeypatch db.compact to raise
def boom():
raise RuntimeError("nope")
dlg._db.compact = boom # type: ignore
called = {"critical": False, "title": None, "text": None}
class DummyMB:
@staticmethod
def information(*args, **kwargs):
return 0
@staticmethod
def critical(parent, title, text, *rest):
called["critical"] = True
called["title"] = title
called["text"] = str(text)
return 0
# Swap QMessageBox used inside the dialog module so signature mismatch can't occur
monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
# Invoke
dlg._compact_btn_clicked()
assert called["critical"]
assert called["title"]
assert called["text"]
class _Host(QWidget):
def __init__(self, themes):
super().__init__()
self.themes = themes
def _make_host_and_dialog(tmp_db_cfg, fresh_db):
# Create a real ThemeManager so we don't have to fake anything here
from PySide6.QtWidgets import QApplication
themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM))
host = _Host(themes)
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host)
return host, dlg
def _clear_qsettings_theme_to_system():
"""Make the radio-button default deterministic across the full suite."""
s = get_settings()
s.clear()
s.setValue("ui/theme", "system")
def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db):
# Ensure no stray theme value from previous tests
_clear_qsettings_theme_to_system()
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(host)
qtbot.addWidget(dlg)
# With fresh settings (system), the 'system' radio should be selected
assert dlg.theme_system.isChecked()
def test_save_selects_system_when_no_explicit_choice(
qtbot, tmp_db_cfg, fresh_db, monkeypatch
):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Ensure neither dark nor light is checked so SYSTEM path is taken
dlg.theme_dark.setChecked(False)
dlg.theme_light.setChecked(False)
# This should not raise
dlg._save()
def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
dlg.theme_dark.setChecked(True)
dlg._save()
def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg._change_key() # returns early
def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "abc"
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return "abc"
# First call yields P1, second yields P2
seq = [P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
dlg._change_key() # returns early
def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Timer that auto-accepts any modal QMessageBox so we don't hang.
def _pump_boxes():
# Try both the active modal and the general top-level enumeration
m = QApplication.activeModalWidget()
if isinstance(m, QMessageBox):
m.accept()
for w in QApplication.topLevelWidgets():
if isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_boxes)
timer.start()
try:
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
seq = [P1, P2, P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
# First run triggers empty-key warning path and return (auto-closed)
dlg._change_key()
# Now make rekey() raise to hit the except block (critical dialog)
def boom(*a, **k):
raise RuntimeError("nope")
dlg._db.rekey = boom
# Return a non-empty matching key twice
class P3:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "secret"
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3())
dlg._change_key()
finally:
timer.stop()
def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Make sure we start with no key saved so it will prompt
dlg.key = ""
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg.save_key_btn.setChecked(True) # toggles and calls handler
# Handler should have undone the checkbox back to False
assert not dlg.save_key_btn.isChecked()

View file

@ -1,636 +0,0 @@
import datetime as _dt
from datetime import datetime, timedelta, date
from bouquin import strings
from PySide6.QtCore import Qt, QPoint, QDate
from PySide6.QtWidgets import QLabel, QWidget
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
class FakeStatsDB:
"""Minimal stub that returns a fixed stats payload."""
def __init__(self):
d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2)
self.stats = (
2, # pages_with_content
5, # total_revisions
"2024-01-02", # page_most_revisions
3, # page_most_revisions_count
{d1: 10, d2: 20}, # words_by_date
30, # total_words
4, # unique_tags
"2024-01-02", # page_most_tags
2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date
)
self.called = False
def gather_stats(self):
self.called = True
return self.stats
def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Make sure we have a known language for label texts
strings.load_strings("en")
db = FakeStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
# Stats were actually requested from the DB
assert db.called
# Window title comes from translations
assert dlg.windowTitle() == strings._("statistics")
# Grab all label texts for simple content checks
label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)}
# Page with most revisions / tags are rendered as "DATE (COUNT)"
assert "2024-01-02 (3)" in label_texts
assert "2024-01-02 (2)" in label_texts
# Heatmap is created and uses "words" by default
words_by_date = db.stats[4]
revisions_by_date = db.stats[-1]
assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date
# Switching the metric to "revisions" should swap the dataset
dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions
qtbot.wait(10)
assert dlg._heatmap._data == revisions_by_date
class EmptyStatsDB:
"""Stub that returns a 'no data yet' stats payload."""
def __init__(self):
self.called = False
def gather_stats(self):
self.called = True
return (
0, # pages_with_content
0, # total_revisions
None, # page_most_revisions
0,
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
0,
{}, # revisions_by_date
)
def test_statistics_dialog_no_data_shows_placeholder(qtbot):
strings.load_strings("en")
db = EmptyStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert db.called
label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)]
assert strings._("stats_no_data") in label_texts
# When there's no data, the heatmap and metric combo shouldn't exist
assert not hasattr(dlg, "metric_combo")
assert not hasattr(dlg, "_heatmap")
def _date(year, month, day):
return date(year, month, day)
# ============================================================================
# DateHeatmapTests - Missing Coverage
# ============================================================================
def test_activity_heatmap_empty_data(qtbot):
"""Test heatmap with empty data dict."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set empty data
heatmap.set_data({})
# Should handle empty data gracefully
assert heatmap._start is None
assert heatmap._end is None
assert heatmap._max_value == 0
# Size hint should return default dimensions
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should not crash
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_none_data(qtbot):
"""Test heatmap with None data."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set None data
heatmap.set_data(None)
assert heatmap._start is None
assert heatmap._end is None
# Paint event should return early
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_click_when_no_data(qtbot):
"""Test clicking heatmap when there's no data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data({})
# Simulate click - should not crash or emit signal
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in the middle of widget
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should not have clicked any date
assert len(clicked_dates) == 0
def test_activity_heatmap_click_outside_grid(qtbot):
"""Test clicking outside the grid area."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set some data
data = {
date(2024, 1, 1): 5,
date(2024, 1, 2): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in top-left margin (before grid starts)
pos = QPoint(5, 5)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_click_beyond_end_date(qtbot):
"""Test clicking on trailing empty cells beyond the last date."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set data that doesn't fill a complete week
data = {
date(2024, 1, 1): 5, # Monday
date(2024, 1, 2): 10, # Tuesday
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Try clicking far to the right (beyond end date)
# This is tricky to target precisely, but we can simulate
pos = QPoint(1000, 50) # Far right
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should either not click or only click valid dates
# If it did click, it should be a valid date within range
if clicked_dates:
assert clicked_dates[0] <= date(2024, 1, 2)
def test_activity_heatmap_click_invalid_row(qtbot):
"""Test clicking below the 7 weekday rows."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 5,
date(2024, 1, 8): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click below the grid (row 8 or higher)
pos = QPoint(100, 500) # Very low Y
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_right_click_ignored(qtbot):
"""Test that right-click is ignored."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 1): 5}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Right click should be ignored
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.RightButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_month_label_rendering(qtbot):
"""Test heatmap spanning multiple months renders month labels."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Data spanning multiple months
data = {
date(2024, 1, 1): 5,
date(2024, 1, 15): 10,
date(2024, 2, 1): 8,
date(2024, 2, 15): 12,
date(2024, 3, 1): 6,
}
heatmap.set_data(data)
# Should calculate proper size
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should work without crashing
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_same_month_continues(qtbot):
"""Test that month labels skip weeks in the same month."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Multiple dates in same month
data = {}
for day in range(1, 29): # January 1-28
data[date(2024, 1, day)] = day
heatmap.set_data(data)
# Should render without issues
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_data_with_zero_values(qtbot):
"""Test heatmap with zero values in data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 0,
date(2024, 1, 2): 5,
date(2024, 1, 3): 0,
}
heatmap.set_data(data)
assert heatmap._max_value == 5
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_single_day(qtbot):
"""Test heatmap with just one day of data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 15): 10}
heatmap.set_data(data)
# Should handle single day
assert heatmap._start is not None
assert heatmap._end is not None
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click should work
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# ============================================================================
# StatisticsDialog Tests
# ============================================================================
def test_statistics_dialog_with_empty_database(qtbot, fresh_db):
"""Test statistics dialog with an empty database."""
strings.load_strings("en")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle empty database gracefully
assert dialog.isVisible()
# Heatmap should be empty
heatmap = dialog.findChild(DateHeatmap)
if heatmap:
# No crash when displaying empty heatmap
qtbot.wait(10)
def test_statistics_dialog_with_data(qtbot, fresh_db):
"""Test statistics dialog with actual data."""
strings.load_strings("en")
# Add some content
fresh_db.save_new_version("2024-01-01", "Hello world", "test")
fresh_db.save_new_version("2024-01-02", "More content here", "test")
fresh_db.save_new_version("2024-01-03", "Even more text", "test")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should display statistics
assert dialog.isVisible()
qtbot.wait(10)
def test_statistics_dialog_gather_stats_exception_handling(
qtbot, fresh_db, monkeypatch
):
"""Test that gather_stats handles exceptions gracefully."""
strings.load_strings("en")
# Make dates_with_content raise an exception
def bad_dates_with_content():
raise RuntimeError("Simulated DB error")
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content)
# Should still create dialog without crashing
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle error gracefully
assert dialog.isVisible()
def test_statistics_dialog_with_sparse_data(qtbot, tmp_db_cfg, fresh_db):
"""Test statistics dialog with sparse data"""
# Add some entries on non-consecutive days
dates = ["2024-01-01", "2024-01-05", "2024-01-10", "2024-01-20"]
for _date in dates:
content = "Word " * 100 # 100 words
fresh_db.save_new_version(_date, content, "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Should create without crashing
assert dialog is not None
def test_statistics_dialog_with_empty_data(qtbot, tmp_db_cfg, fresh_db):
"""Test statistics dialog with no data"""
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Should handle empty data gracefully
assert dialog is not None
def test_statistics_dialog_date_range_selection(qtbot, tmp_db_cfg, fresh_db):
"""Test changing metric in statistics dialog"""
# Add some test data
for i in range(10):
date = QDate.currentDate().addDays(-i).toString("yyyy-MM-dd")
fresh_db.save_new_version(date, f"Content for day {i}", "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Change metric to revisions
idx = dialog.metric_combo.findData("revisions")
if idx >= 0:
dialog.metric_combo.setCurrentIndex(idx)
qtbot.wait(50)
# Change back to words
idx = dialog.metric_combo.findData("words")
if idx >= 0:
dialog.metric_combo.setCurrentIndex(idx)
qtbot.wait(50)
def test_heatmap_with_varying_word_counts(qtbot):
"""Test heatmap color scaling with varying word counts"""
today = datetime.now().date()
start = today - timedelta(days=30)
entries = {}
# Create entries with varying word counts
for i in range(31):
date = start + timedelta(days=i)
entries[date] = i * 50 # Increasing word counts
heatmap = DateHeatmap()
heatmap.set_data(entries)
qtbot.addWidget(heatmap)
heatmap.show()
# Should paint without errors
assert heatmap.isVisible()
def test_heatmap_single_day(qtbot):
"""Test heatmap with single day of data"""
today = datetime.now().date()
entries = {today: 500}
heatmap = DateHeatmap()
heatmap.set_data(entries)
qtbot.addWidget(heatmap)
heatmap.show()
assert heatmap.isVisible()
def test_statistics_dialog_metric_changes(qtbot, tmp_db_cfg, fresh_db):
"""Test various metric selections"""
# Add data spanning multiple months
base_date = QDate.currentDate().addDays(-90)
for i in range(90):
date = base_date.addDays(i).toString("yyyy-MM-dd")
fresh_db.save_new_version(date, f"Day {i} content with many words", "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Test each metric option
for i in range(dialog.metric_combo.count()):
dialog.metric_combo.setCurrentIndex(i)
qtbot.wait(50)
def test_heatmap_date_beyond_end(qtbot, fresh_db):
"""Test clicking on a date beyond the end date in heatmap."""
# Create entries spanning a range
today = date.today()
start = today - timedelta(days=30)
data = {}
for i in range(20):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set data
heatmap.set_data(data)
# Try to click beyond the end date - should return early
# Calculate a position that would be beyond the end
if heatmap._start and heatmap._end:
cell_span = heatmap._cell + heatmap._gap
weeks = ((heatmap._end - heatmap._start).days + 6) // 7
# Click beyond the last week
x = heatmap._margin_left + (weeks + 1) * cell_span + 5
y = heatmap._margin_top + 3 * cell_span + 5
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
def test_heatmap_click_outside_grid(qtbot, fresh_db):
"""Test clicking outside the heatmap grid area."""
today = date.today()
start = today - timedelta(days=7)
data = {}
for i in range(7):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Click in the margin (outside grid)
x = heatmap._margin_left - 10 # Before the grid
y = heatmap._margin_top - 10 # Above the grid
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
# Should not crash, just return early
def test_heatmap_click_invalid_row(qtbot, fresh_db):
"""Test clicking on an invalid row (>= 7)."""
today = date.today()
start = today - timedelta(days=7)
data = {}
for i in range(7):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Click below row 6 (day of week > Sunday)
cell_span = heatmap._cell + heatmap._gap
x = heatmap._margin_left + 5
y = heatmap._margin_top + 7 * cell_span + 5 # Row 7, which is invalid
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
# Should return early, not crash
def test_heatmap_month_label_continuation(qtbot, fresh_db):
"""Test that month labels don't repeat when continuing in same month."""
# Create a date range that spans multiple weeks within the same month
today = date.today()
# Use a date that's guaranteed to be mid-month
start = date(today.year, today.month, 1)
data = {}
for i in range(21):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Force a repaint to execute paintEvent
heatmap.repaint()
# The month continuation logic (line 175) should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash

View file

@ -1,17 +0,0 @@
from bouquin import strings
def test_load_strings_uses_system_locale_and_fallback():
# pass a bogus locale to trigger fallback-to-default
strings.load_strings("zz")
assert strings._("next") # key exists in base translations
def test_load_strings_french():
strings.load_strings("fr")
assert strings._("today") == "Aujourd'hui" # translation exists in French
def test_load_strings_italian():
strings.load_strings("it")
assert strings._("today") == "Oggi" # translation exists in Italian

View file

@ -1,195 +0,0 @@
import types
from PySide6.QtWidgets import QFileDialog
from PySide6.QtGui import QTextCursor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
# point to the temp encrypted DB
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# first tab is today's date
date1 = w.calendar.selectedDate()
initial_count = w.tab_widget.count()
# opening the same date should NOT create a new tab
w._open_date_in_tab(date1)
assert w.tab_widget.count() == initial_count
assert w.tab_widget.currentWidget().current_date == date1
# opening a different date should create exactly one new tab
date2 = date1.addDays(1)
w._open_date_in_tab(date2)
assert w.tab_widget.count() == initial_count + 1
assert w.tab_widget.currentWidget().current_date == date2
# jumping back to date1 just focuses the existing tab
w._open_date_in_tab(date1)
assert w.tab_widget.count() == initial_count + 1
assert w.tab_widget.currentWidget().current_date == date1
def test_toolbar_signals_dispatch_once_per_click(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
tb = w.toolBar
# Spy on the first tab's editor
calls1 = {
"bold": 0,
"italic": 0,
"strike": 0,
"code": 0,
"heading": 0,
"bullets": 0,
"numbers": 0,
"checkboxes": 0,
}
def mk(key):
def _spy(self, *a, **k):
calls1[key] += 1
return _spy
w.editor.apply_weight = types.MethodType(mk("bold"), w.editor)
w.editor.apply_italic = types.MethodType(mk("italic"), w.editor)
w.editor.apply_strikethrough = types.MethodType(mk("strike"), w.editor)
w.editor.apply_code = types.MethodType(mk("code"), w.editor)
w.editor.apply_heading = types.MethodType(mk("heading"), w.editor)
w.editor.toggle_bullets = types.MethodType(mk("bullets"), w.editor)
w.editor.toggle_numbers = types.MethodType(mk("numbers"), w.editor)
w.editor.toggle_checkboxes = types.MethodType(mk("checkboxes"), w.editor)
# Click all the things once
tb.boldRequested.emit()
tb.italicRequested.emit()
tb.strikeRequested.emit()
tb.codeRequested.emit()
tb.headingRequested.emit(24)
tb.bulletsRequested.emit()
tb.numbersRequested.emit()
tb.checkboxesRequested.emit()
assert all(v == 1 for v in calls1.values()) # fired once each
# Switch to a new tab and make sure clicks go ONLY to the active editor
date2 = w.calendar.selectedDate().addDays(1)
w._open_date_in_tab(date2)
calls2 = {"bold": 0}
w.editor.apply_weight = types.MethodType(
lambda self: calls2.__setitem__("bold", calls2["bold"] + 1), w.editor
)
tb.boldRequested.emit()
assert calls1["bold"] == 1
assert calls2["bold"] == 1
w._open_date_in_tab(date2.addDays(-1)) # back to first tab
tb.boldRequested.emit()
assert calls1["bold"] == 2
assert calls2["bold"] == 1
def test_history_and_insert_image_not_duplicated(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# History dialog opens exactly once
opened = {"count": 0}
def fake_exec(self):
opened["count"] += 1
return 0 # Rejected
monkeypatch.setattr(HistoryDialog, "exec", fake_exec, raising=False)
w.toolBar.historyRequested.emit()
assert opened["count"] == 1
# Insert image: simulate user selecting one file, and ensure it's inserted once
dummy = tmp_path / "x.png"
dummy.write_bytes(b"\x89PNG\r\n\x1a\n")
inserted = {"count": 0}
def fake_insert(self, p):
inserted["count"] += 1
w.editor.insert_image_from_path = types.MethodType(fake_insert, w.editor)
monkeypatch.setattr(
QFileDialog,
"getOpenFileNames",
lambda *a, **k: ([str(dummy)], "Images (*.png)"),
raising=False,
)
w.toolBar.insertImageRequested.emit()
assert inserted["count"] == 1
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
w.editor.from_markdown("**bold**\n- [ ] task\n~~strike~~")
assert w.editor.highlighter is not None
assert w.editor.highlighter.document() is w.editor.document()
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Tab 1 content
w.editor.from_markdown("alpha bravo charlie")
w.findBar.show_bar()
w.findBar.edit.setText("bravo")
w.findBar.find_next()
assert w.editor.textCursor().selectedText() == "bravo"
# Tab 2 content (contains the query too)
date2 = w.calendar.selectedDate().addDays(1)
w._open_date_in_tab(date2)
w.editor.from_markdown("x bravo y bravo z")
w.editor.moveCursor(QTextCursor.Start)
w.findBar.find_next()
assert w.editor.textCursor().selectedText() == "bravo"

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import pytest
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from bouquin.theme import Theme, ThemeConfig, ThemeManager
@ -14,39 +14,8 @@ def test_theme_manager_apply_light_and_dark(app):
assert isinstance(app.palette(), QPalette)
@pytest.mark.gui
def test_theme_manager_system_roundtrip(app, qtbot):
cfg = ThemeConfig(theme=Theme.SYSTEM)
mgr = ThemeManager(app, cfg)
mgr.apply(cfg.theme)
def _make_themes(theme):
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=theme))
def test_register_and_restyle_calendar_and_overlay(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal)
themes.register_lock_overlay(ov)
# Force a restyle pass (covers the "is not None" branches)
themes._restyle_registered()
def test_apply_dark_styles_cover_css_paths(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal) # drives _apply_calendar_theme (dark path)
themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path)

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@ import pytest
from PySide6.QtWidgets import QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.toolbar import ToolBar
@pytest.fixture
@ -14,6 +13,10 @@ def editor(app, qtbot):
return ed
from bouquin.toolbar import ToolBar
@pytest.mark.gui
def test_toolbar_signals_and_styling(qtbot, editor):
host = QWidget()
qtbot.addWidget(host)
@ -39,29 +42,3 @@ def test_toolbar_signals_and_styling(qtbot, editor):
tb.strikeRequested.emit()
tb.headingRequested.emit(24)
assert editor.to_markdown()
def test_style_letter_button_paths(app, qtbot):
parent = QWidget()
qtbot.addWidget(parent)
# Create toolbar
from bouquin.markdown_editor import MarkdownEditor
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
tb = ToolBar(parent)
qtbot.addWidget(tb)
# Action not added to toolbar -> no widget, early return
from PySide6.QtGui import QAction
stray = QAction("Stray", tb)
tb._style_letter_button(stray, "Z") # should not raise
# Now add an action to toolbar and style with tooltip
act = tb.addAction("Temp")
tb._style_letter_button(act, "T", tooltip="Tip here")
btn = tb.widgetForAction(act)
assert btn.toolTip() == "Tip here"
assert btn.accessibleName() == "Tip here"

View file

@ -1,534 +0,0 @@
import pytest
from unittest.mock import Mock, patch
import subprocess
from bouquin.version_check import VersionChecker
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QPixmap
def test_version_checker_init(app):
"""Test VersionChecker initialization."""
parent = QWidget()
checker = VersionChecker(parent)
assert checker._parent is parent
def test_version_checker_init_no_parent(app):
"""Test VersionChecker initialization without parent."""
checker = VersionChecker()
assert checker._parent is None
def test_current_version_returns_version(app):
"""Test getting current version."""
checker = VersionChecker()
with patch("importlib.metadata.version", return_value="1.2.3"):
version = checker.current_version()
assert version == "1.2.3"
def test_current_version_fallback_on_error(app):
"""Test current version fallback when package not found."""
checker = VersionChecker()
import importlib.metadata
with patch(
"importlib.metadata.version",
side_effect=importlib.metadata.PackageNotFoundError("Not found"),
):
version = checker.current_version()
assert version == "0.0.0"
def test_parse_version_simple(app):
"""Test parsing simple version string."""
result = VersionChecker._parse_version("1.2.3")
assert result == (1, 2, 3)
def test_parse_version_complex(app):
"""Test parsing complex version string with extra text."""
result = VersionChecker._parse_version("v1.2.3-beta")
assert result == (1, 2, 3)
def test_parse_version_no_numbers(app):
"""Test parsing version string with no numbers."""
result = VersionChecker._parse_version("invalid")
assert result == (0,)
def test_parse_version_single_number(app):
"""Test parsing version with single number."""
result = VersionChecker._parse_version("5")
assert result == (5,)
def test_is_newer_version_true(app):
"""Test detecting newer version."""
checker = VersionChecker()
assert checker._is_newer_version("1.2.3", "1.2.2") is True
assert checker._is_newer_version("2.0.0", "1.9.9") is True
assert checker._is_newer_version("1.3.0", "1.2.9") is True
def test_is_newer_version_false(app):
"""Test detecting same or older version."""
checker = VersionChecker()
assert checker._is_newer_version("1.2.3", "1.2.3") is False
assert checker._is_newer_version("1.2.2", "1.2.3") is False
assert checker._is_newer_version("0.9.9", "1.0.0") is False
def test_logo_pixmap(app):
"""Test generating logo pixmap."""
checker = VersionChecker()
pixmap = checker._logo_pixmap(96)
assert isinstance(pixmap, QPixmap)
assert not pixmap.isNull()
def test_logo_pixmap_different_sizes(app):
"""Test generating logo pixmap with different sizes."""
checker = VersionChecker()
pixmap_small = checker._logo_pixmap(48)
pixmap_large = checker._logo_pixmap(128)
assert not pixmap_small.isNull()
assert not pixmap_large.isNull()
def test_show_version_dialog(qtbot, app):
"""Test showing version dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch.object(QMessageBox, "exec") as mock_exec:
with patch("importlib.metadata.version", return_value="1.0.0"):
checker.show_version_dialog()
# Dialog should have been shown
assert mock_exec.called
def test_check_for_updates_network_error(qtbot, app):
"""Test check for updates when network request fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch("requests.get", side_effect=Exception("Network error")):
with patch.object(QMessageBox, "warning") as mock_warning:
checker.check_for_updates()
# Should show warning
assert mock_warning.called
def test_check_for_updates_empty_response(qtbot, app):
"""Test check for updates with empty version string."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = " "
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch.object(QMessageBox, "warning") as mock_warning:
checker.check_for_updates()
# Should show warning about empty version
assert mock_warning.called
def test_check_for_updates_already_latest(qtbot, app):
"""Test check for updates when already on latest version."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "1.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "information") as mock_info:
checker.check_for_updates()
# Should show info that we're on latest
assert mock_info.called
def test_check_for_updates_new_version_available_declined(qtbot, app):
"""Test check for updates when new version is available but user declines."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
# Should not proceed to download
checker.check_for_updates()
def test_check_for_updates_new_version_available_accepted(qtbot, app):
"""Test check for updates when new version is available and user accepts."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
with patch.object(
checker, "_download_and_verify_appimage"
) as mock_download:
checker.check_for_updates()
# Should call download
mock_download.assert_called_once_with("2.0.0")
def test_download_file_success(qtbot, app, tmp_path):
"""Test downloading a file successfully."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"data" * 25]) # 100 bytes
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
def test_download_file_with_progress(qtbot, app, tmp_path):
"""Test downloading a file with progress dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"x" * 100, b"y" * 100])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
from PySide6.QtWidgets import QProgressDialog
mock_progress = Mock(spec=QProgressDialog)
mock_progress.wasCanceled = Mock(return_value=False)
mock_progress.value = Mock(return_value=0)
with patch("requests.get", return_value=mock_response):
checker._download_file(
"http://example.com/file", dest_path, progress=mock_progress
)
# Progress should have been updated
assert mock_progress.setValue.called
def test_download_file_cancelled(qtbot, app, tmp_path):
"""Test cancelling a file download."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"x" * 100])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
from PySide6.QtWidgets import QProgressDialog
mock_progress = Mock(spec=QProgressDialog)
mock_progress.wasCanceled = Mock(return_value=True)
mock_progress.value = Mock(return_value=0)
with patch("requests.get", return_value=mock_response):
with pytest.raises(RuntimeError):
checker._download_file(
"http://example.com/file", dest_path, progress=mock_progress
)
def test_download_file_no_content_length(qtbot, app, tmp_path):
"""Test downloading file without Content-Length header."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
def test_download_and_verify_appimage_download_cancelled(qtbot, app, tmp_path):
"""Test AppImage download when user cancels."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(
checker, "_download_file", side_effect=RuntimeError("Download cancelled")
):
with patch.object(QMessageBox, "information") as mock_info:
checker._download_and_verify_appimage("2.0.0")
# Should show cancellation message
assert mock_info.called
def test_download_and_verify_appimage_download_error(qtbot, app, tmp_path):
"""Test AppImage download when download fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(
checker, "_download_file", side_effect=Exception("Network error")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error message
assert mock_critical.called
def test_download_and_verify_appimage_gpg_key_error(qtbot, app, tmp_path):
"""Test AppImage verification when GPG key cannot be read."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch(
"importlib.resources.files", side_effect=Exception("Key not found")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about GPG key
assert mock_critical.called
def test_download_and_verify_appimage_gpg_not_found(qtbot, app, tmp_path):
"""Test AppImage verification when GPG is not installed."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
with patch(
"subprocess.run", side_effect=FileNotFoundError("gpg not found")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about GPG not found
assert mock_critical.called
def test_download_and_verify_appimage_verification_failed(qtbot, app, tmp_path):
"""Test AppImage verification when signature verification fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
# First subprocess call (import) succeeds, second (verify) fails
mock_error = subprocess.CalledProcessError(1, "gpg")
mock_error.stderr = b"Verification failed"
with patch("subprocess.run", side_effect=[None, mock_error]):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about verification
assert mock_critical.called
def test_download_and_verify_appimage_success(qtbot, app, tmp_path):
"""Test successful AppImage download and verification."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
with patch("subprocess.run"): # Both calls succeed
with patch.object(QMessageBox, "information") as mock_info:
checker._download_and_verify_appimage("2.0.0")
# Should show success message
assert mock_info.called
def test_version_comparison_edge_cases(app):
"""Test version comparison with edge cases."""
checker = VersionChecker()
# Different lengths
assert checker._is_newer_version("1.0.0.1", "1.0.0") is True
assert checker._is_newer_version("1.0", "1.0.0") is False
# Large numbers
assert checker._is_newer_version("10.0.0", "9.9.9") is True
assert checker._is_newer_version("1.100.0", "1.99.0") is True
def test_download_file_creates_parent_directory(qtbot, app, tmp_path):
"""Test that download creates parent directory if needed."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "subdir" / "nested" / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
assert dest_path.parent.exists()
def test_show_version_dialog_check_button_clicked(qtbot, app):
"""Test clicking 'Check for updates' button in version dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_box = Mock(spec=QMessageBox)
check_button = Mock()
mock_box.clickedButton = Mock(return_value=check_button)
mock_box.addButton = Mock(return_value=check_button)
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch("bouquin.version_check.QMessageBox", return_value=mock_box):
with patch.object(checker, "check_for_updates") as mock_check:
checker.show_version_dialog()
# check_for_updates should be called when button is clicked
if mock_box.clickedButton() is check_button:
assert mock_check.called
def test_parse_version_with_letters(app):
"""Test parsing version strings with letters."""
result = VersionChecker._parse_version("1.2.3rc1")
assert 1 in result
assert 2 in result
assert 3 in result
def test_download_file_invalid_content_length(qtbot, app, tmp_path):
"""Test downloading file with invalid Content-Length header."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {"Content-Length": "invalid"}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
# Should handle gracefully
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
def test_version_checker_creation(qtbot):
"""Test creating a VersionChecker instance."""
widget = QWidget()
qtbot.addWidget(widget)
checker = VersionChecker(widget)
assert checker is not None
def test_current_version(qtbot):
"""Test getting the current version."""
widget = QWidget()
qtbot.addWidget(widget)
checker = VersionChecker(widget)
version = checker.current_version()
# Version should be a string
assert isinstance(version, str)
assert len(version) > 0

View file

@ -1,26 +0,0 @@
from bouquin.db import DBManager
from bouquin.flow_layout import FlowLayout
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.statistics_dialog import DateHeatMap
DBManager.row_factory
DateHeatMap.minimumSizeHint
FlowLayout.itemAt
FlowLayout.expandingDirections
FlowLayout.hasHeightForWidth
FlowLayout.heightForWidth
MarkdownEditor.apply_weight
MarkdownEditor.apply_italic
MarkdownEditor.apply_strikethrough
MarkdownEditor.apply_code
MarkdownEditor.apply_heading
MarkdownEditor.contextMenuEvent
MarkdownEditor.toggle_bullets
MarkdownEditor.toggle_numbers
MarkdownEditor.toggle_checkboxes
MarkdownHighlighter.highlightBlock