diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..87b67ff --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,37 @@ +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 + diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml new file mode 100644 index 0000000..5bb3794 --- /dev/null +++ b/.forgejo/workflows/lint.yml @@ -0,0 +1,27 @@ +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/ diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml new file mode 100644 index 0000000..18ced32 --- /dev/null +++ b/.forgejo/workflows/trivy.yml @@ -0,0 +1,26 @@ +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 . diff --git a/.gitignore b/.gitignore index 2352872..851b242 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ .pytest_cache dist .coverage +*.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 203a3f2..7839225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,159 @@ +# 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 + # 0.1.12.1 * Fix newline after URL keeps URL style formatting diff --git a/README.md b/README.md index a70013d..5cf77e5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ # Bouquin +
+ Bouquin logo +
## Introduction -Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher. +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. 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. @@ -11,42 +18,78 @@ 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. +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. -## Screenshot +## Screenshots -![Screenshot of Bouquin](./screenshot.png) +### General view +
+ Bouquin screenshot +
-![Screenshot of Bouquin in dark mode](./screenshot_dark.png) +### History panes +
+ Screenshot of Bouquin History Preview Pane + Screenshot of Bouquin History Diff Pane +
-## Features +### Tags +
+ Screenshot of Bouquin Tag Manager screen +
+ +### Time Logging +
+ Screenshot of Bouquin Time Log screens +
+ + +### Statistics +
+ Bouquin statistics +
+ + +## Some of the features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings - * Every 'page' is linked to the calendar day - * All changes are version controlled, with ability to view/diff versions and revert - * Text is HTML with basic styling + * 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. * Images are supported - * Search + * Search all pages, or find text on current page + * Add and manage tags * 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, txt, html, csv, markdown or .sql (for sqlite3) + * Export the database to json, 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 themes + * Dark and light theme support * Automatically generate checkboxes when typing 'TODO' - * Optionally automatically move unchecked checkboxes from yesterday to today, on startup + * 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 ## How to install -Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions). +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). ### 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 diff --git a/bouquin.desktop b/bouquin.desktop new file mode 100644 index 0000000..aa519c3 --- /dev/null +++ b/bouquin.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Type=Application +Name=Bouquin +Exec=Bouquin.AppImage +Icon=bouquin +Categories=Office diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py new file mode 100644 index 0000000..9cc727c --- /dev/null +++ b/bouquin/bug_report_dialog.py @@ -0,0 +1,121 @@ +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})", + ) diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py new file mode 100644 index 0000000..e462574 --- /dev/null +++ b/bouquin/code_highlighter.py @@ -0,0 +1,367 @@ +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 "" + + def deserialize(self, text: str): + """Deserialize metadata from text.""" + self._block_languages.clear() + + # Look for metadata comment at the end + match = re.search(r"", 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 diff --git a/bouquin/db.py b/bouquin/db.py index b6c937b..ba6b6ce 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1,17 +1,58 @@ from __future__ import annotations import csv +import datetime as _dt +import hashlib import html import json -import os +import markdown +import re from dataclasses import dataclass -from markdownify import markdownify as md from pathlib import Path from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple +from typing import List, Sequence, Tuple, Dict + + +from . import strings Entry = Tuple[str, str] +TagRow = Tuple[int, str, str] +ProjectRow = Tuple[int, str] # (id, name) +ActivityRow = Tuple[int, str] # (id, name) +TimeLogRow = Tuple[ + int, # id + str, # page_date (yyyy-MM-dd) + int, + str, # project_id, project_name + int, + str, # activity_id, activity_name + int, # minutes + str | None, # note +] + +_TAG_COLORS = [ + "#FFB3BA", # soft red + "#FFDFBA", # soft orange + "#FFFFBA", # soft yellow + "#BAFFC9", # soft green + "#BAE1FF", # soft blue + "#E0BAFF", # soft purple + "#FFC4B3", # soft coral + "#FFD8B1", # soft peach + "#FFF1BA", # soft light yellow + "#E9FFBA", # soft lime + "#CFFFE5", # soft mint + "#BAFFF5", # soft aqua + "#BAF0FF", # soft cyan + "#C7E9FF", # soft sky blue + "#C7CEFF", # soft periwinkle + "#F0BAFF", # soft lavender pink + "#FFBAF2", # soft magenta + "#FFD1F0", # soft pink + "#EBD5C7", # soft beige + "#EAEAEA", # soft gray +] @dataclass @@ -21,6 +62,11 @@ class DBConfig: idle_minutes: int = 15 # 0 = never lock theme: str = "system" move_todos: bool = False + tags: bool = True + time_log: bool = True + reminders: bool = True + locale: str = "en" + font_size: int = 11 class DBManager: @@ -64,8 +110,12 @@ class DBManager: # Not OK: rows of problems returned details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None) raise sqlite.IntegrityError( - "SQLCipher integrity check failed" - + (f": {details}" if details else f" ({len(rows)} issue(s) reported)") + strings._("db_sqlcipher_integrity_check_failed") + + ( + f": {details}" + if details + else f" ({len(rows)} {strings._('db_issues_reported')})" + ) ) def _ensure_schema(self) -> None: @@ -77,7 +127,6 @@ class DBManager: # Always keep FKs on cur.execute("PRAGMA foreign_keys = ON;") - # Create new versioned schema if missing (< 0.1.5) cur.executescript( """ CREATE TABLE IF NOT EXISTS pages ( @@ -98,33 +147,72 @@ class DBManager: CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no); CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at); + + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name); + + CREATE TABLE IF NOT EXISTS page_tags ( + page_date TEXT NOT NULL, -- FK to pages.date + tag_id INTEGER NOT NULL, -- FK to tags.id + PRIMARY KEY (page_date, tag_id), + FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS time_log ( + id INTEGER PRIMARY KEY, + page_date TEXT NOT NULL, -- FK to pages.date (yyyy-MM-dd) + project_id INTEGER NOT NULL, -- FK to projects.id + activity_id INTEGER NOT NULL, -- FK to activities.id + minutes INTEGER NOT NULL, -- duration in minutes + note TEXT, + created_at TEXT NOT NULL DEFAULT ( + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ), + FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE RESTRICT, + FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS ix_time_log_date + ON time_log(page_date); + CREATE INDEX IF NOT EXISTS ix_time_log_project + ON time_log(project_id); + CREATE INDEX IF NOT EXISTS ix_time_log_activity + ON time_log(activity_id); + + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY, + text TEXT NOT NULL, + time_str TEXT NOT NULL, -- HH:MM + reminder_type TEXT NOT NULL, -- once|daily|weekdays|weekly + weekday INTEGER, -- 0-6 for weekly (0=Mon) + date_iso TEXT, -- for once type + active INTEGER NOT NULL DEFAULT 1, -- 0=inactive, 1=active + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + CREATE INDEX IF NOT EXISTS ix_reminders_active + ON reminders(active); """ ) - - # If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate. - pre_0_1_5 = cur.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';" - ).fetchone() - pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None - - if pre_0_1_5 and pages_empty: - # Seed pages and versions (all as version 1) - cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;") - cur.execute( - "INSERT INTO versions(date, version_no, content) " - "SELECT date, 1, content FROM entries;" - ) - # Point head to v1 for each page - cur.execute( - """ - UPDATE pages - SET current_version_id = ( - SELECT v.id FROM versions v - WHERE v.date = pages.date AND v.version_no = 1 - ); - """ - ) - cur.execute("DROP TABLE IF EXISTS entries;") self.conn.commit() def rekey(self, new_key: str) -> None: @@ -132,8 +220,6 @@ class DBManager: Change the SQLCipher passphrase in-place, then reopen the connection with the new key to verify. """ - if self.conn is None: - raise RuntimeError("Database is not connected") cur = self.conn.cursor() # Change the encryption key of the currently open database cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone() @@ -144,7 +230,7 @@ class DBManager: self.conn = None self.cfg.key = new_key if not self.connect(): - raise sqlite.Error("Re-open failed after rekey") + raise sqlite.Error(strings._("db_reopen_failed_after_rekey")) def get_entry(self, date_iso: str) -> str: """ @@ -164,22 +250,31 @@ class DBManager: def search_entries(self, text: str) -> list[str]: """ - Search for entries by term. This only works against the latest - version of the page. + Search for entries by term or tag name. + This only works against the latest version of the page. """ cur = self.conn.cursor() - pattern = f"%{text}%" + q = text.strip() + pattern = f"%{q.lower()}%" + rows = cur.execute( """ - SELECT p.date, v.content + SELECT DISTINCT p.date, v.content FROM pages AS p JOIN versions AS v ON v.id = p.current_version_id + LEFT JOIN page_tags pt + ON pt.page_date = p.date + LEFT JOIN tags t + ON t.id = pt.tag_id WHERE TRIM(v.content) <> '' - AND v.content LIKE LOWER(?) ESCAPE '\\' + AND ( + LOWER(v.content) LIKE ? + OR LOWER(COALESCE(t.name, '')) LIKE ? + ) ORDER BY p.date DESC; """, - (pattern,), + (pattern, pattern), ).fetchall() return [(r[0], r[1]) for r in rows] @@ -193,7 +288,8 @@ class DBManager: """ SELECT p.date FROM pages p - JOIN versions v ON v.id = p.current_version_id + JOIN versions v + ON v.id = p.current_version_id WHERE TRIM(v.content) <> '' ORDER BY p.date; """ @@ -212,8 +308,6 @@ class DBManager: Append a new version for this date. Returns (version_id, version_no). If set_current=True, flips the page head to this new version. """ - if self.conn is None: - raise RuntimeError("Database is not connected") with self.conn: # transaction cur = self.conn.cursor() # Ensure page row exists @@ -281,7 +375,9 @@ class DBManager: "SELECT date FROM versions WHERE id=?;", (version_id,) ).fetchone() if row is None or row["date"] != date_iso: - raise ValueError("version_id does not belong to the given date") + raise ValueError( + strings._("db_version_id_does_not_belong_to_the_given_date") + ) with self.conn: cur.execute( @@ -289,6 +385,17 @@ class DBManager: (version_id, date_iso), ) + def delete_version(self, *, version_id: int) -> bool | None: + """ + Delete a specific version by version_id. + """ + cur = self.conn.cursor() + with self.conn: + cur.execute( + "DELETE FROM versions WHERE id=?;", + (version_id,), + ) + # ------------------------- Export logic here ------------------------# def get_all_entries(self) -> List[Entry]: """ @@ -323,52 +430,6 @@ class DBManager: writer.writerow(["date", "content"]) # header writer.writerows(entries) - def export_txt( - self, - entries: Sequence[Entry], - file_path: str, - separator: str = "\n\n— — — — —\n\n", - strip_html: bool = True, - ) -> None: - """ - Strip the HTML from the latest version of the pages - and save to a text file. - """ - import re, html as _html - - # Precompiled patterns - STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?") - COMMENT_RE = re.compile(r"", re.S) - BR_RE = re.compile(r"(?i)") - BLOCK_END_RE = re.compile(r"(?i)") - TAG_RE = re.compile(r"<[^>]+>") - WS_ENDS_RE = re.compile(r"[ \\t]+\\n") - MULTINEWLINE_RE = re.compile(r"\\n{3,}") - - def _strip(s: str) -> str: - # 1) Remove ", + "", "", f"

{html.escape(title)}

", ] for d, c in entries: + body_html = markdown.markdown( + c, + extensions=[ + "extra", + "nl2br", + ], + output_format="html5", + ) + parts.append( - f"
{c}
" + f"
" + f"
" + f"
{body_html}
" + f"
" ) parts.append("") @@ -398,28 +478,16 @@ class DBManager: self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" ) -> None: """ - Export to HTML, similar to export_html, but then convert to Markdown - using markdownify, and finally save to file. + Export the data to a markdown file. Since the data is already Markdown, + nothing more to do. """ - parts = [ - "", - '', - "", - f"

{html.escape(title)}

", - ] + parts = [] for d, c in entries: - parts.append( - f"
{c}
" - ) - parts.append("") - - # Convert html to markdown - md_items = [] - for item in parts: - md_items.append(md(item, heading_style="ATX")) + parts.append(f"# {d}") + parts.append(c) with open(file_path, "w", encoding="utf-8") as f: - f.write("\n".join(md_items)) + f.write("\n".join(parts)) def export_sql(self, file_path: str) -> None: """ @@ -440,29 +508,6 @@ class DBManager: cur.execute("SELECT sqlcipher_export('backup')") cur.execute("DETACH DATABASE backup") - def export_by_extension(self, file_path: str) -> None: - """ - Fallback catch-all that runs one of the above functions based on - the extension of the file name that was chosen by the user. - """ - entries = self.get_all_entries() - ext = os.path.splitext(file_path)[1].lower() - - if ext == ".json": - self.export_json(entries, file_path) - elif ext == ".csv": - self.export_csv(entries, file_path) - elif ext == ".txt": - self.export_txt(entries, file_path) - elif ext in {".html", ".htm"}: - self.export_html(entries, file_path) - elif ext in {".sql", ".sqlite"}: - self.export_sql(file_path) - elif ext == ".md": - self.export_markdown(entries, file_path) - else: - raise ValueError(f"Unsupported extension: {ext}") - def compact(self) -> None: """ Runs VACUUM on the db. @@ -471,9 +516,624 @@ class DBManager: cur = self.conn.cursor() cur.execute("VACUUM") except Exception as e: - print(f"Error: {e}") + print(f"{strings._('error')}: {e}") + + # -------- Tags: helpers ------------------------------------------- + + def _default_tag_colour(self, name: str) -> str: + """ + Deterministically pick a colour for a tag name from a small palette. + """ + if not name: + return "#CCCCCC" + h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) # nosec + return _TAG_COLORS[h % len(_TAG_COLORS)] + + # -------- Tags: per-page ------------------------------------------- + + def get_tags_for_page(self, date_iso: str) -> list[TagRow]: + """ + Return (id, name, color) for all tags attached to this page/date. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT t.id, t.name, t.color + FROM page_tags pt + JOIN tags t ON t.id = pt.tag_id + WHERE pt.page_date = ? + ORDER BY LOWER(t.name); + """, + (date_iso,), + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def set_tags_for_page(self, date_iso: str, tag_names: Sequence[str]) -> None: + """ + Replace the tag set for a page with the given names. + Creates new tags as needed (with auto colours). + Tags are case-insensitive - reuses existing tag if found with different case. + """ + # Normalise + dedupe (case-insensitive) + clean_names = [] + seen = set() + for name in tag_names: + name = name.strip() + if not name: + continue + if name.lower() in seen: + continue + seen.add(name.lower()) + clean_names.append(name) + + with self.conn: + cur = self.conn.cursor() + + # Ensure the page row exists even if there's no content yet + cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,)) + + if not clean_names: + # Just clear all tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + return + + # For each tag name, check if it exists with different casing + # If so, reuse that existing tag; otherwise create new + final_tag_names = [] + for name in clean_names: + # Look for existing tag (case-insensitive) + existing = cur.execute( + "SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,) + ).fetchone() + + if existing: + # Use the existing tag's exact name + final_tag_names.append(existing["name"]) + else: + # Create new tag with the provided casing + cur.execute( + """ + INSERT OR IGNORE INTO tags(name, color) + VALUES (?, ?); + """, + (name, self._default_tag_colour(name)), + ) + final_tag_names.append(name) + + # Lookup ids for the final tag names + placeholders = ",".join("?" for _ in final_tag_names) + rows = cur.execute( + f""" + SELECT id, name + FROM tags + WHERE name IN ({placeholders}); + """, # nosec + tuple(final_tag_names), + ).fetchall() + ids_by_name = {r["name"]: r["id"] for r in rows} + + # Reset page_tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + for name in final_tag_names: + tag_id = ids_by_name.get(name) + if tag_id is not None: + cur.execute( + """ + INSERT OR IGNORE INTO page_tags(page_date, tag_id) + VALUES (?, ?); + """, + (date_iso, tag_id), + ) + + # -------- Tags: global management ---------------------------------- + + def list_tags(self) -> list[TagRow]: + """ + Return all tags in the database. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT id, name, color + FROM tags + ORDER BY LOWER(name); + """ + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def add_tag(self, name: str, color: str) -> None: + """ + Update a tag's name and colour. + """ + name = name.strip() + color = color.strip() or "#CCCCCC" + + try: + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + INSERT INTO tags + (name, color) + VALUES (?, ?); + """, + (name, color), + ) + except sqlite.IntegrityError as e: + if "UNIQUE constraint failed: tags.name" in str(e): + raise sqlite.IntegrityError( + strings._("tag_already_exists_with_that_name") + ) from e + + def update_tag(self, tag_id: int, name: str, color: str) -> None: + """ + Update a tag's name and colour. + """ + name = name.strip() + color = color.strip() or "#CCCCCC" + + try: + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + UPDATE tags + SET name = ?, color = ? + WHERE id = ?; + """, + (name, color, tag_id), + ) + except sqlite.IntegrityError as e: + if "UNIQUE constraint failed: tags.name" in str(e): + raise sqlite.IntegrityError( + strings._("tag_already_exists_with_that_name") + ) from e + + def delete_tag(self, tag_id: int) -> None: + """ + Delete a tag entirely (removes it from all pages). + """ + with self.conn: + cur = self.conn.cursor() + cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) + cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) + + def get_pages_for_tag(self, tag_name: str) -> list[Entry]: + """ + Return (date, content) for pages that have the given tag. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT p.date, v.content + FROM pages AS p + JOIN versions AS v + ON v.id = p.current_version_id + JOIN page_tags pt + ON pt.page_date = p.date + JOIN tags t + ON t.id = pt.tag_id + WHERE LOWER(t.name) = LOWER(?) + ORDER BY p.date DESC; + """, + (tag_name,), + ).fetchall() + return [(r[0], r[1]) for r in rows] + + # ---------- helpers for word counting ---------- + def _strip_markdown(self, text: str) -> str: + """ + Cheap markdown-ish stripper for word counting. + We only need approximate numbers. + """ + if not text: + return "" + + # Remove fenced code blocks + text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL) + # Remove inline code + text = re.sub(r"`[^`]+`", " ", text) + # [text](url) → text + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + # Remove emphasis markers, headings, etc. + text = re.sub(r"[#*_>]+", " ", text) + # Strip simple HTML tags + text = re.sub(r"<[^>]+>", " ", text) + + return text + + def _count_words(self, text: str) -> int: + text = self._strip_markdown(text) + words = re.findall(r"\b\w+\b", text, flags=re.UNICODE) + return len(words) + + def gather_stats(self): + """Compute all the numbers the Statistics dialog needs in one place.""" + + # 1) pages with content (current version only) + try: + pages_with_content_list = self.dates_with_content() + except Exception: + pages_with_content_list = [] + pages_with_content = len(pages_with_content_list) + + cur = self.conn.cursor() + + # 2 & 3) total revisions + page with most revisions + per-date counts + total_revisions = 0 + page_most_revisions = None + page_most_revisions_count = 0 + revisions_by_date: Dict[_dt.date, int] = {} + + rows = cur.execute( + """ + SELECT date, COUNT(*) AS c + FROM versions + GROUP BY date + ORDER BY date; + """ + ).fetchall() + + for r in rows: + date_iso = r["date"] + c = int(r["c"]) + total_revisions += c + + if c > page_most_revisions_count: + page_most_revisions_count = c + page_most_revisions = date_iso + + d = _dt.date.fromisoformat(date_iso) + revisions_by_date[d] = c + + # 4) total words + per-date words (current version only) + entries = self.get_all_entries() + total_words = 0 + words_by_date: Dict[_dt.date, int] = {} + + for date_iso, content in entries: + wc = self._count_words(content or "") + total_words += wc + d = _dt.date.fromisoformat(date_iso) + words_by_date[d] = wc + + # tags + page with most tags + + rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall() + unique_tags = int(rows[0]["total_unique"]) if rows else 0 + + rows = cur.execute( + """ + SELECT page_date, COUNT(*) AS c + FROM page_tags + GROUP BY page_date + ORDER BY c DESC, page_date ASC + LIMIT 1; + """ + ).fetchall() + + if rows: + page_most_tags = rows[0]["page_date"] + page_most_tags_count = int(rows[0]["c"]) + else: + page_most_tags = None + page_most_tags_count = 0 + + return ( + 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, + ) + + # -------- Time logging: projects & activities --------------------- + + def list_projects(self) -> list[ProjectRow]: + cur = self.conn.cursor() + rows = cur.execute( + "SELECT id, name FROM projects ORDER BY LOWER(name);" + ).fetchall() + return [(r["id"], r["name"]) for r in rows] + + def add_project(self, name: str) -> int: + name = name.strip() + if not name: + raise ValueError("empty project name") + with self.conn: + cur = self.conn.cursor() + cur.execute( + "INSERT OR IGNORE INTO projects(name) VALUES (?);", + (name,), + ) + row = cur.execute( + "SELECT id, name FROM projects WHERE name = ?;", + (name,), + ).fetchone() + return row["id"] + + def rename_project(self, project_id: int, new_name: str) -> None: + new_name = new_name.strip() + if not new_name: + return + with self.conn: + self.conn.execute( + "UPDATE projects SET name = ? WHERE id = ?;", + (new_name, project_id), + ) + + def delete_project(self, project_id: int) -> None: + with self.conn: + self.conn.execute( + "DELETE FROM projects WHERE id = ?;", + (project_id,), + ) + + def list_activities(self) -> list[ActivityRow]: + cur = self.conn.cursor() + rows = cur.execute( + "SELECT id, name FROM activities ORDER BY LOWER(name);" + ).fetchall() + return [(r["id"], r["name"]) for r in rows] + + def add_activity(self, name: str) -> int: + name = name.strip() + if not name: + raise ValueError("empty activity name") + with self.conn: + cur = self.conn.cursor() + cur.execute( + "INSERT OR IGNORE INTO activities(name) VALUES (?);", + (name,), + ) + row = cur.execute( + "SELECT id, name FROM activities WHERE name = ?;", + (name,), + ).fetchone() + return row["id"] + + def rename_activity(self, activity_id: int, new_name: str) -> None: + new_name = new_name.strip() + if not new_name: + return + with self.conn: + self.conn.execute( + "UPDATE activities SET name = ? WHERE id = ?;", + (new_name, activity_id), + ) + + def delete_activity(self, activity_id: int) -> None: + with self.conn: + self.conn.execute( + "DELETE FROM activities WHERE id = ?;", + (activity_id,), + ) + + # -------- Time logging: entries ----------------------------------- + + def add_time_log( + self, + date_iso: str, + project_id: int, + activity_id: int, + minutes: int, + note: str | None = None, + ) -> int: + with self.conn: + cur = self.conn.cursor() + # Ensure a page row exists even if there is no text content yet + cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,)) + cur.execute( + """ + INSERT INTO time_log(page_date, project_id, activity_id, minutes, note) + VALUES (?, ?, ?, ?, ?); + """, + (date_iso, project_id, activity_id, minutes, note), + ) + return cur.lastrowid + + def update_time_log( + self, + entry_id: int, + project_id: int, + activity_id: int, + minutes: int, + note: str | None = None, + ) -> None: + with self.conn: + self.conn.execute( + """ + UPDATE time_log + SET project_id = ?, activity_id = ?, minutes = ?, note = ? + WHERE id = ?; + """, + (project_id, activity_id, minutes, note, entry_id), + ) + + def delete_time_log(self, entry_id: int) -> None: + with self.conn: + self.conn.execute( + "DELETE FROM time_log WHERE id = ?;", + (entry_id,), + ) + + def time_log_for_date(self, date_iso: str) -> list[TimeLogRow]: + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT + t.id, + t.page_date, + t.project_id, + p.name AS project_name, + t.activity_id, + a.name AS activity_name, + t.minutes, + t.note + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.page_date = ? + ORDER BY LOWER(p.name), LOWER(a.name), t.id; + """, + (date_iso,), + ).fetchall() + + result: list[TimeLogRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["page_date"], + r["project_id"], + r["project_name"], + r["activity_id"], + r["activity_name"], + r["minutes"], + r["note"], + ) + ) + return result + + def time_report( + self, + project_id: int, + start_date_iso: str, + end_date_iso: str, + granularity: str = "day", # 'day' | 'week' | 'month' + ) -> list[tuple[str, str, int]]: + """ + Return (time_period, activity_name, total_minutes) tuples between start and end + for a project, grouped by period and activity. + time_period is: + - 'YYYY-MM-DD' for day + - 'YYYY-WW' for week + - 'YYYY-MM' for month + """ + if granularity == "day": + bucket_expr = "page_date" + elif granularity == "week": + # ISO-like year-week; SQLite weeks start at 00 + bucket_expr = "strftime('%Y-%W', page_date)" + else: # month + bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM + + cur = self.conn.cursor() + rows = cur.execute( + f""" + SELECT + {bucket_expr} AS bucket, + a.name AS activity_name, + t.note AS note, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + GROUP BY bucket, activity_name + ORDER BY bucket, LOWER(activity_name); + """, # nosec + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + return [ + (r["bucket"], r["activity_name"], r["note"], r["total_minutes"]) + for r in rows + ] def close(self) -> None: if self.conn is not None: self.conn.close() self.conn = None + + # ------------------------- Reminders logic here ------------------------# + def save_reminder(self, reminder) -> int: + """Save or update a reminder. Returns the reminder ID.""" + cur = self.conn.cursor() + if reminder.id: + # Update existing + cur.execute( + """ + UPDATE reminders + SET text = ?, time_str = ?, reminder_type = ?, + weekday = ?, date_iso = ?, active = ? + WHERE id = ? + """, + ( + reminder.text, + reminder.time_str, + reminder.reminder_type.value, + reminder.weekday, + reminder.date_iso, + 1 if reminder.active else 0, + reminder.id, + ), + ) + self.conn.commit() + return reminder.id + else: + # Insert new + cur.execute( + """ + INSERT INTO reminders (text, time_str, reminder_type, weekday, date_iso, active) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + reminder.text, + reminder.time_str, + reminder.reminder_type.value, + reminder.weekday, + reminder.date_iso, + 1 if reminder.active else 0, + ), + ) + self.conn.commit() + return cur.lastrowid + + def get_all_reminders(self): + """Get all reminders.""" + from .reminders import Reminder, ReminderType + + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT id, text, time_str, reminder_type, weekday, date_iso, active + FROM reminders + ORDER BY time_str + """ + ).fetchall() + + result = [] + for r in rows: + result.append( + Reminder( + id=r["id"], + text=r["text"], + time_str=r["time_str"], + reminder_type=ReminderType(r["reminder_type"]), + weekday=r["weekday"], + date_iso=r["date_iso"], + active=bool(r["active"]), + ) + ) + return result + + def update_reminder_active(self, reminder_id: int, active: bool) -> None: + """Update the active status of a reminder.""" + cur = self.conn.cursor() + cur.execute( + "UPDATE reminders SET active = ? WHERE id = ?", + (1 if active else 0, reminder_id), + ) + self.conn.commit() + + def delete_reminder(self, reminder_id: int) -> None: + """Delete a reminder.""" + cur = self.conn.cursor() + cur.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) + self.conn.commit() diff --git a/bouquin/editor.py b/bouquin/editor.py deleted file mode 100644 index ee45921..0000000 --- a/bouquin/editor.py +++ /dev/null @@ -1,1015 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import base64, re - -from PySide6.QtGui import ( - QColor, - QDesktopServices, - QFont, - QFontDatabase, - QImage, - QImageReader, - QPalette, - QPixmap, - QTextCharFormat, - QTextCursor, - QTextFrameFormat, - QTextListFormat, - QTextBlockFormat, - QTextImageFormat, - QTextDocument, -) -from PySide6.QtCore import ( - Qt, - QUrl, - Signal, - Slot, - QRegularExpression, - QBuffer, - QByteArray, - QIODevice, - QTimer, -) -from PySide6.QtWidgets import QTextEdit, QApplication - -from .theme import Theme, ThemeManager - - -class Editor(QTextEdit): - linkActivated = Signal(str) - - _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') - _CODE_BG = QColor(245, 245, 245) - _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames - _HEADING_SIZES = (24.0, 18.0, 14.0) - _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - _DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I) - # --- Checkbox hack --- # - _CHECK_UNCHECKED = "\u2610" # ☐ - _CHECK_CHECKED = "\u2611" # ☑ - _CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space - _CHECKBOX_SCALE = 1.35 - - def __init__(self, theme_manager: ThemeManager, *args, **kwargs): - super().__init__(*args, **kwargs) - tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") - self.setTabStopDistance(tab_w) - - self.setTextInteractionFlags( - Qt.TextInteractionFlag.TextEditorInteraction - | Qt.TextInteractionFlag.LinksAccessibleByMouse - | Qt.TextInteractionFlag.LinksAccessibleByKeyboard - ) - - self.setAcceptRichText(True) - - # If older docs have a baked-in color, normalize once: - self._retint_anchors_to_palette() - - self._themes = theme_manager - self._apply_code_theme() # set initial code colors - # Refresh on theme change - self._themes.themeChanged.connect(self._on_theme_changed) - self._themes.themeChanged.connect( - lambda _t: QTimer.singleShot(0, self._apply_code_theme) - ) - - self._linkifying = False - self.textChanged.connect(self._linkify_document) - self.viewport().setMouseTracking(True) - - # ---------------- Helpers ---------------- # - - def _iter_frames(self, root=None): - """Depth-first traversal of all frames (including root if passed).""" - doc = self.document() - stack = [root or doc.rootFrame()] - while stack: - f = stack.pop() - yield f - it = f.begin() - while not it.atEnd(): - cf = it.currentFrame() - if cf is not None: - stack.append(cf) - it += 1 - - def _is_code_frame(self, frame, tolerant: bool = False) -> bool: - """ - True if 'frame' is a code frame. - - tolerant=False: require our property marker - - tolerant=True: also accept legacy background or non-wrapping heuristic - """ - ff = frame.frameFormat() - if ff.property(self._CODE_FRAME_PROP): - return True - if not tolerant: - return False - - # Background colour check - bg = ff.background() - if bg.style() != Qt.NoBrush: - c = bg.color() - if c.isValid(): - if ( - abs(c.red() - 245) <= 2 - and abs(c.green() - 245) <= 2 - and abs(c.blue() - 245) <= 2 - ): - return True - if ( - abs(c.red() - 43) <= 2 - and abs(c.green() - 43) <= 2 - and abs(c.blue() - 43) <= 2 - ): - return True - - # Heuristic: mostly non-wrapping blocks - doc = self.document() - bc = QTextCursor(doc) - bc.setPosition(frame.firstPosition()) - blocks = codeish = 0 - while bc.position() < frame.lastPosition(): - b = bc.block() - if not b.isValid(): - break - blocks += 1 - if b.blockFormat().nonBreakableLines(): - codeish += 1 - bc.setPosition(b.position() + b.length()) - return blocks > 0 and (codeish / blocks) >= 0.6 - - def _nearest_code_frame(self, cursor, tolerant: bool = False): - """Walk up parents from the cursor and return the first code frame.""" - f = cursor.currentFrame() - while f: - if self._is_code_frame(f, tolerant=tolerant): - return f - f = f.parentFrame() - return None - - def _code_block_formats(self, fg: QColor | None = None): - """(QTextBlockFormat, QTextCharFormat) for code blocks.""" - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) - - bf = QTextBlockFormat() - bf.setTopMargin(0) - bf.setBottomMargin(0) - bf.setLeftMargin(12) - bf.setRightMargin(12) - bf.setNonBreakableLines(True) - - cf = QTextCharFormat() - cf.setFont(mono) - cf.setFontFixedPitch(True) - if fg is not None: - cf.setForeground(fg) - return bf, cf - - def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat: - """Standard frame format for code blocks.""" - ff = QTextFrameFormat() - ff.setBackground(bg) - ff.setPadding(6) - ff.setBorder(0) - ff.setLeftMargin(0) - ff.setRightMargin(0) - ff.setTopMargin(0) - ff.setBottomMargin(0) - ff.setProperty(self._CODE_FRAME_PROP, True) - return ff - - def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None): - """Apply background to frame and standard code formats to all blocks inside.""" - ff = frame.frameFormat() - ff.setBackground(bg) - frame.setFrameFormat(ff) - - bf, cf = self._code_block_formats(fg) - doc = self.document() - bc = QTextCursor(doc) - bc.setPosition(frame.firstPosition()) - while bc.position() < frame.lastPosition(): - bc.select(QTextCursor.BlockUnderCursor) - bc.mergeBlockFormat(bf) - bc.mergeBlockCharFormat(cf) - if not bc.movePosition(QTextCursor.NextBlock): - break - - def _safe_block_insertion_cursor(self): - """ - Return a cursor positioned for inserting an inline object (like an image): - - not inside a code frame (moves to after frame if necessary) - - at a fresh paragraph (inserts a block if mid-line) - Also updates the editor's current cursor to that position. - """ - c = QTextCursor(self.textCursor()) - frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only - if frame: - out = QTextCursor(self.document()) - out.setPosition(frame.lastPosition()) - self.setTextCursor(out) - c = self.textCursor() - if c.positionInBlock() != 0: - c.insertBlock() - return c - - def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage: - """If the image is wider than viewport*ratio, scale it down proportionally.""" - if self.viewport(): - max_w = int(self.viewport().width() * ratio) - if img.width() > max_w: - return img.scaledToWidth(max_w, Qt.SmoothTransformation) - return img - - def _approx(self, a: float, b: float, eps: float = 0.5) -> bool: - return abs(float(a) - float(b)) <= eps - - def _is_heading_typing(self) -> bool: - """Is the current *insertion* format using a heading size?""" - bf = self.textCursor().blockFormat() - if bf.headingLevel() > 0: - return True - - def _apply_normal_typing(self): - """Switch the *insertion* format to Normal (default size, normal weight).""" - nf = QTextCharFormat() - nf.setFontPointSize(self.font().pointSizeF()) - nf.setFontWeight(QFont.Weight.Normal) - self.mergeCurrentCharFormat(nf) - - def _code_theme_colors(self): - """Return (bg, fg) for code blocks based on the effective palette.""" - pal = QApplication.instance().palette() - # simple luminance check on the window color - win = pal.color(QPalette.Window) - is_dark = win.value() < 128 - if is_dark: - bg = QColor(43, 43, 43) # dark code background - fg = pal.windowText().color() # readable on dark - else: - bg = QColor(245, 245, 245) # light code background - fg = pal.text().color() # readable on light - return bg, fg - - def _apply_code_theme(self): - """Retint all code frames (even those reloaded from HTML) to match the current theme.""" - bg, fg = self._code_theme_colors() - self._CODE_BG = bg # used by future apply_code() calls - - doc = self.document() - cur = QTextCursor(doc) - cur.beginEditBlock() - try: - for f in self._iter_frames(doc.rootFrame()): - if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True): - self._retint_code_frame(f, bg, fg) - finally: - cur.endEditBlock() - self.viewport().update() - - def _trim_url_end(self, url: str) -> str: - # strip common trailing punctuation not part of the URL - trimmed = url.rstrip(".,;:!?\"'") - # drop an unmatched closing ) or ] at the very end - if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"): - trimmed = trimmed[:-1] - if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"): - trimmed = trimmed[:-1] - return trimmed - - def _linkify_document(self): - if self._linkifying: - return - self._linkifying = True - - try: - block = self.textCursor().block() - start_pos = block.position() - text = block.text() - - cur = QTextCursor(self.document()) - cur.beginEditBlock() - - it = self._URL_RX.globalMatch(text) - while it.hasNext(): - m = it.next() - s = start_pos + m.capturedStart() - raw = m.captured(0) - url = self._trim_url_end(raw) - if not url: - continue - - e = s + len(url) - cur.setPosition(s) - cur.setPosition(e, QTextCursor.KeepAnchor) - - if url.startswith("www."): - href = "https://" + url - else: - href = url - - fmt = QTextCharFormat() - fmt.setAnchor(True) - fmt.setAnchorHref(href) # always refresh to the latest full URL - fmt.setFontUnderline(True) - fmt.setForeground(self.palette().brush(QPalette.Link)) - - cur.mergeCharFormat(fmt) # merge so we don't clobber other styling - - cur.endEditBlock() - finally: - self._linkifying = False - - def _to_qimage(self, obj) -> QImage | None: - if isinstance(obj, QImage): - return None if obj.isNull() else obj - if isinstance(obj, QPixmap): - qi = obj.toImage() - return None if qi.isNull() else qi - if isinstance(obj, (bytes, bytearray)): - qi = QImage.fromData(obj) - return None if qi.isNull() else qi - return None - - def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str: - ba = QByteArray() - buf = QBuffer(ba) - buf.open(QIODevice.WriteOnly) - img.save(buf, fmt.upper()) - b64 = base64.b64encode(bytes(ba)).decode("ascii") - mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}" - return f"data:{mime};base64,{b64}" - - def _image_name_to_qimage(self, name: str) -> QImage | None: - res = self.document().resource(QTextDocument.ImageResource, QUrl(name)) - return res if isinstance(res, QImage) and not res.isNull() else None - - def to_html_with_embedded_images(self) -> str: - """ - Return the document HTML with all image src's replaced by data: URLs, - so it is self-contained for storage in the DB. - """ - # 1) Walk the document collecting name -> data: URL - name_to_data = {} - cur = QTextCursor(self.document()) - cur.movePosition(QTextCursor.Start) - while True: - cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = cur.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - name = imgfmt.name() - if name and name not in name_to_data: - img = self._image_name_to_qimage(name) - if img: - name_to_data[name] = self._qimage_to_data_url(img, "PNG") - if cur.atEnd(): - break - cur.clearSelection() - - # 2) Serialize and replace names with data URLs - html = self.document().toHtml() - for old, data_url in name_to_data.items(): - html = html.replace(f'src="{old}"', f'src="{data_url}"') - html = html.replace(f"src='{old}'", f"src='{data_url}'") - return html - - # ---------------- Image insertion & sizing (DRY’d) ---------------- # - - def _insert_qimage_at_cursor(self, img: QImage, autoscale=True): - c = self._safe_block_insertion_cursor() - if autoscale: - img = self._scale_to_viewport(img) - c.insertImage(img) - c.insertBlock() # one blank line after the image - - def _image_info_at_cursor(self): - """ - Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None) - """ - # Try current position (select 1 char forward) - tc = QTextCursor(self.textCursor()) - tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = tc.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - img = self._resolve_image_resource(imgfmt) - return tc, imgfmt, img - - # Try previous char (if caret is just after the image) - tc = QTextCursor(self.textCursor()) - if tc.position() > 0: - tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1) - tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = tc.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - img = self._resolve_image_resource(imgfmt) - return tc, imgfmt, img - - return None, None, None - - def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None: - """ - Fetch the original QImage backing the inline image, if available. - """ - name = imgfmt.name() - if name: - try: - img = self.document().resource(QTextDocument.ImageResource, QUrl(name)) - if isinstance(img, QImage) and not img.isNull(): - return img - except Exception: - pass - return None # fallback handled by callers - - def _apply_image_size( - self, - tc: QTextCursor, - imgfmt: QTextImageFormat, - new_w: float, - orig_img: QImage | None, - ): - # compute height proportionally - if orig_img and orig_img.width() > 0: - ratio = new_w / orig_img.width() - new_h = max(1.0, orig_img.height() * ratio) - else: - # fallback: keep current aspect ratio if we have it - cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w - cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w - ratio = new_w / max(1.0, cur_w) - new_h = max(1.0, cur_h * ratio) - - imgfmt.setWidth(max(1.0, new_w)) - imgfmt.setHeight(max(1.0, new_h)) - tc.mergeCharFormat(imgfmt) - - def _scale_image_at_cursor(self, factor: float): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - base_w = imgfmt.width() - if base_w <= 0 and orig: - base_w = orig.width() - if base_w <= 0: - return - self._apply_image_size(tc, imgfmt, base_w * factor, orig) - - def _fit_image_to_editor_width(self): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - if not self.viewport(): - return - target = int(self.viewport().width() * 0.92) - self._apply_image_size(tc, imgfmt, target, orig) - - def _set_image_width_dialog(self): - from PySide6.QtWidgets import QInputDialog - - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - # propose current display width or original width - cur_w = ( - int(imgfmt.width()) - if imgfmt.width() > 0 - else (orig.width() if orig else 400) - ) - w, ok = QInputDialog.getInt( - self, "Set image width", "Width (px):", cur_w, 1, 10000, 10 - ) - if ok: - self._apply_image_size(tc, imgfmt, float(w), orig) - - def _reset_image_size(self): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt or not orig: - return - self._apply_image_size(tc, imgfmt, float(orig.width()), orig) - - # ---------------- Context menu ---------------- # - - def contextMenuEvent(self, e): - menu = self.createStandardContextMenu() - tc, imgfmt, orig = self._image_info_at_cursor() - if imgfmt: - menu.addSeparator() - sub = menu.addMenu("Image size") - sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9)) - sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1)) - sub.addAction("Fit to editor width", self._fit_image_to_editor_width) - sub.addAction("Set width…", self._set_image_width_dialog) - sub.addAction("Reset to original", self._reset_image_size) - menu.exec(e.globalPos()) - - # ---------------- Clipboard / DnD ---------------- # - - def insertFromMimeData(self, source): - # 1) Direct image from clipboard - if source.hasImage(): - img = self._to_qimage(source.imageData()) - if img is not None: - self._insert_qimage_at_cursor(img, autoscale=True) - return - - # 2) File URLs (drag/drop or paste) - if source.hasUrls(): - paths = [] - non_local_urls = [] - for url in source.urls(): - if url.isLocalFile(): - path = url.toLocalFile() - if path.lower().endswith(self._IMAGE_EXTS): - paths.append(path) - else: - # Non-image file: insert as link - self.textCursor().insertHtml( - f'{Path(path).name}' - ) - self.textCursor().insertBlock() - else: - non_local_urls.append(url) - - if paths: - self.insert_images(paths) - - for url in non_local_urls: - self.textCursor().insertHtml( - f'{url.toString()}' - ) - self.textCursor().insertBlock() - - if paths or non_local_urls: - return - - # 3) HTML with data: image - if source.hasHtml(): - html = source.html() - m = self._DATA_IMG_RX.search(html or "") - if m: - try: - data = base64.b64decode(m.group(1)) - img = QImage.fromData(data) - if not img.isNull(): - self._insert_qimage_at_cursor(img, autoscale=True) - return - except Exception: - pass # fall through - - # 4) Everything else → default behavior - super().insertFromMimeData(source) - - @Slot(list) - def insert_images(self, paths: list[str], autoscale=True): - """ - Insert one or more images at the cursor. Large images can be auto-scaled - to fit the viewport width while preserving aspect ratio. - """ - c = self._safe_block_insertion_cursor() - - for path in paths: - reader = QImageReader(path) - img = reader.read() - if img.isNull(): - continue - - if autoscale: - img = self._scale_to_viewport(img) - - c.insertImage(img) - c.insertBlock() # put each image on its own line - - # ---------------- Mouse & key handling ---------------- # - - def mouseReleaseEvent(self, e): - if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): - href = self.anchorAt(e.pos()) - if href: - QDesktopServices.openUrl(QUrl.fromUserInput(href)) - self.linkActivated.emit(href) - return - super().mouseReleaseEvent(e) - - def mouseMoveEvent(self, e): - if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()): - self.viewport().setCursor(Qt.PointingHandCursor) - else: - self.viewport().setCursor(Qt.IBeamCursor) - super().mouseMoveEvent(e) - - def mousePressEvent(self, e): - if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier): - cur = self.cursorForPosition(e.pos()) - b = cur.block() - state, pref = self._checkbox_info_for_block(b) - if state is not None: - col = cur.position() - b.position() - if col <= max(1, pref): # clicked on ☐/☑ (and the following space) - self._set_block_checkbox_state(b, not state) - return - return super().mousePressEvent(e) - - def keyPressEvent(self, e): - key = e.key() - - if key in (Qt.Key_Space, Qt.Key_Tab): - c = self.textCursor() - b = c.block() - pos_in_block = c.position() - b.position() - - if ( - pos_in_block >= 4 - and b.text().startswith("TODO") - and b.text()[:pos_in_block] == "TODO" - and self._checkbox_info_for_block(b)[0] is None - ): - tcur = QTextCursor(self.document()) - tcur.setPosition(b.position()) # start of block - tcur.setPosition( - b.position() + 4, QTextCursor.KeepAnchor - ) # select "TODO" - tcur.beginEditBlock() - tcur.removeSelectedText() - tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ " - tcur.endEditBlock() - - # visuals: size bump - if hasattr(self, "_style_checkbox_glyph"): - self._style_checkbox_glyph(b) - - # caret after the inserted prefix; swallow the key (we already added a space) - c.setPosition(b.position() + 2) - self.setTextCursor(c) - return - - # not a TODO-at-start case - self._break_anchor_for_next_char() - return super().keyPressEvent(e) - - if key in (Qt.Key_Return, Qt.Key_Enter): - c = self.textCursor() - - # If we're on an empty line inside a code frame, consume Enter and jump out - if c.block().length() == 1: - frame = self._nearest_code_frame(c, tolerant=False) - if frame: - out = QTextCursor(self.document()) - out.setPosition(frame.lastPosition()) # after the frame's contents - self.setTextCursor(out) - super().insertPlainText("\n") # start a normal paragraph - return - - # --- CHECKBOX handling: continue on Enter; "escape" on second Enter --- - b = c.block() - state, pref = self._checkbox_info_for_block(b) - if state is not None and not c.hasSelection(): - text_after = b.text()[pref:].strip() - if c.atBlockEnd() and text_after == "": - # Empty checkbox item -> remove the prefix and insert a plain new line - cur = QTextCursor(self.document()) - cur.setPosition(b.position()) - cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref) - cur.removeSelectedText() - return super().keyPressEvent(e) - else: - # Normal continuation: new checkbox on the next line - super().keyPressEvent(e) # make the new block - super().insertPlainText(self._CHECK_UNCHECKED + " ") - if hasattr(self, "_style_checkbox_glyph"): - self._style_checkbox_glyph(self.textCursor().block()) - return - - # Follow-on style: if we typed a heading and press Enter at end of block, - # new paragraph should revert to Normal. - if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing(): - super().keyPressEvent(e) # insert the new paragraph - self._apply_normal_typing() # make the *new* paragraph Normal for typing - return - - # If we were at end-of-line, make the *new* line plain (don’t keep URL styling) - if not c.hasSelection() and c.atBlockEnd(): - super().keyPressEvent(e) # insert the new paragraph - self._break_anchor_for_next_char() # clear anchor/underline/color for typing - return - - # otherwise default handling - return super().keyPressEvent(e) - - def _break_anchor_for_next_char(self): - """ - Ensure the *next* typed character is not part of a hyperlink. - Only strips link-specific attributes; leaves bold/italic/underline etc intact. - """ - # What we're about to type with - ins_fmt = self.currentCharFormat() - # What the cursor is sitting on - cur_fmt = self.textCursor().charFormat() - - # Do nothing unless either side indicates we're in/propagating an anchor - if not ( - ins_fmt.isAnchor() - or cur_fmt.isAnchor() - or ins_fmt.fontUnderline() - or ins_fmt.foreground().style() != Qt.NoBrush - ): - return - - nf = QTextCharFormat(ins_fmt) - # stop the link itself - nf.setAnchor(False) - nf.setAnchorHref("") - # also stop the link *styling* - nf.setFontUnderline(False) - nf.clearForeground() - - self.setCurrentCharFormat(nf) - - def merge_on_sel(self, fmt): - """ - Sets the styling on the selected characters or the insertion position. - """ - cursor = self.textCursor() - if cursor.hasSelection(): - cursor.mergeCharFormat(fmt) - self.mergeCurrentCharFormat(fmt) - - # ====== Checkbox core ====== - def _base_point_size_for_block(self, block) -> float: - # Try the block's char format, then editor font - sz = block.charFormat().fontPointSize() - if sz <= 0: - sz = self.fontPointSize() - if sz <= 0: - sz = self.font().pointSizeF() or 12.0 - return float(sz) - - def _style_checkbox_glyph(self, block): - """Apply larger size (and optional symbol font) to the single ☐/☑ char.""" - state, _ = self._checkbox_info_for_block(block) - if state is None: - return - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only - - base = self._base_point_size_for_block(block) - fmt = QTextCharFormat() - fmt.setFontPointSize(base * self._CHECKBOX_SCALE) - # keep the glyph centered on the text baseline - fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle) - - c.mergeCharFormat(fmt) - - def _checkbox_info_for_block(self, block): - """Return (state, prefix_len): state in {None, False, True}, prefix_len in chars.""" - text = block.text() - m = self._CHECK_RX.match(text) - if not m: - return None, 0 - ch = m.group(1) - state = True if ch == self._CHECK_CHECKED else False - return state, m.end() - - def _set_block_checkbox_present(self, block, present: bool): - state, pref = self._checkbox_info_for_block(block) - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.beginEditBlock() - try: - if present and state is None: - c.insertText(self._CHECK_UNCHECKED + " ") - state = False - self._style_checkbox_glyph(block) - else: - if state is not None: - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref) - c.removeSelectedText() - state = None - finally: - c.endEditBlock() - - return state - - def _set_block_checkbox_state(self, block, checked: bool): - """Switch ☐/☑ at the start of the block.""" - state, pref = self._checkbox_info_for_block(block) - if state is None: - return - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol - c.beginEditBlock() - try: - c.removeSelectedText() - c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED) - self._style_checkbox_glyph(block) - finally: - c.endEditBlock() - - # Public API used by toolbar - def toggle_checkboxes(self): - """ - Toggle checkbox prefix on/off for the current block(s). - If all targeted blocks already have a checkbox, remove them; otherwise add. - """ - c = self.textCursor() - doc = self.document() - - if c.hasSelection(): - start = doc.findBlock(c.selectionStart()) - end = doc.findBlock(c.selectionEnd() - 1) - else: - start = end = c.block() - - # Decide intent: add or remove? - b = start - all_have = True - while True: - state, _ = self._checkbox_info_for_block(b) - if state is None: - all_have = False - break - if b == end: - break - b = b.next() - - # Apply - b = start - while True: - self._set_block_checkbox_present(b, present=not all_have) - if b == end: - break - b = b.next() - - @Slot() - def apply_weight(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - weight = ( - QFont.Weight.Normal - if cur.fontWeight() == QFont.Weight.Bold - else QFont.Weight.Bold - ) - fmt.setFontWeight(weight) - self.merge_on_sel(fmt) - - @Slot() - def apply_italic(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontItalic(not cur.fontItalic()) - self.merge_on_sel(fmt) - - @Slot() - def apply_underline(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontUnderline(not cur.fontUnderline()) - self.merge_on_sel(fmt) - - @Slot() - def apply_strikethrough(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontStrikeOut(not cur.fontStrikeOut()) - self.merge_on_sel(fmt) - - @Slot() - def apply_code(self): - c = self.textCursor() - if not c.hasSelection(): - c.select(QTextCursor.BlockUnderCursor) - - ff = self._new_code_frame_format(self._CODE_BG) - - c.beginEditBlock() - try: - c.insertFrame(ff) # with a selection, this wraps the selection - - # Format all blocks inside the new frame (keep fg=None on creation) - frame = self._nearest_code_frame(c, tolerant=False) - if frame: - self._retint_code_frame(frame, self._CODE_BG, fg=None) - finally: - c.endEditBlock() - - @Slot(int) - def apply_heading(self, size: int): - """ - Set heading point size for typing. If there's a selection, also apply bold - to that selection (for H1..H3). "Normal" clears bold on the selection. - """ - # Map toolbar's sizes to heading levels - level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0 - - c = self.textCursor() - - # On-screen look - ins = QTextCharFormat() - if size: - ins.setFontPointSize(float(size)) - ins.setFontWeight(QFont.Weight.Bold) - else: - ins.setFontPointSize(self.font().pointSizeF()) - ins.setFontWeight(QFont.Weight.Normal) - self.mergeCurrentCharFormat(ins) - - # Apply heading level to affected block(s) - def set_level_for_block(cur): - bf = cur.blockFormat() - if hasattr(bf, "setHeadingLevel"): - bf.setHeadingLevel(level) # 0 clears heading - cur.mergeBlockFormat(bf) - - if c.hasSelection(): - start, end = c.selectionStart(), c.selectionEnd() - bc = QTextCursor(self.document()) - bc.setPosition(start) - while True: - set_level_for_block(bc) - if bc.position() >= end: - break - bc.movePosition(QTextCursor.EndOfBlock) - if bc.position() >= end: - break - bc.movePosition(QTextCursor.NextBlock) - else: - bc = QTextCursor(c) - set_level_for_block(bc) - - def toggle_bullets(self): - c = self.textCursor() - lst = c.currentList() - if lst and lst.format().style() == QTextListFormat.Style.ListDisc: - lst.remove(c.block()) - return - fmt = QTextListFormat() - fmt.setStyle(QTextListFormat.Style.ListDisc) - c.createList(fmt) - - def toggle_numbers(self): - c = self.textCursor() - lst = c.currentList() - if lst and lst.format().style() == QTextListFormat.Style.ListDecimal: - lst.remove(c.block()) - return - fmt = QTextListFormat() - fmt.setStyle(QTextListFormat.Style.ListDecimal) - c.createList(fmt) - - @Slot(Theme) - def _on_theme_changed(self, _theme: Theme): - # Defer one event-loop tick so widgets have the new palette - QTimer.singleShot(0, self._retint_anchors_to_palette) - QTimer.singleShot(0, self._apply_code_theme) - - @Slot() - def _retint_anchors_to_palette(self, *_): - # Always read from the *application* palette to avoid stale widget palette - app = QApplication.instance() - link_brush = app.palette().brush(QPalette.Link) - doc = self.document() - cur = QTextCursor(doc) - cur.beginEditBlock() - block = doc.firstBlock() - while block.isValid(): - it = block.begin() - while not it.atEnd(): - frag = it.fragment() - if frag.isValid(): - fmt = frag.charFormat() - if fmt.isAnchor(): - new_fmt = QTextCharFormat(fmt) - new_fmt.setForeground(link_brush) # force palette link color - start = frag.position() - cur.setPosition(start) - cur.movePosition( - QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, - frag.length(), - ) # select exactly this fragment - cur.setCharFormat(new_fmt) - it += 1 - block = block.next() - cur.endEditBlock() - self.viewport().update() - - def setHtml(self, html: str) -> None: - super().setHtml(html) - - doc = self.document() - block = doc.firstBlock() - while block.isValid(): - self._style_checkbox_glyph(block) # Apply checkbox styling to each block - block = block.next() - - # Ensure anchors adopt the palette color on startup - self._retint_anchors_to_palette() - self._apply_code_theme() diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index 47490d6..ae0206b 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -17,13 +17,14 @@ from PySide6.QtWidgets import ( QTextEdit, ) +from . import strings + class FindBar(QWidget): """Widget for finding text in the Editor""" - closed = ( - Signal() - ) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor + # emitted when the bar is hidden (Esc/✕), so caller can refocus editor + closed = Signal() def __init__( self, @@ -31,23 +32,28 @@ class FindBar(QWidget): shortcut_parent: QWidget | None = None, parent: QWidget | None = None, ): - super().__init__(parent) - self.editor = editor - # UI + super().__init__(parent) + + # 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) layout = QHBoxLayout(self) layout.setContentsMargins(6, 0, 6, 0) - layout.addWidget(QLabel("Find:")) + layout.addWidget(QLabel(strings._("find"))) + self.edit = QLineEdit(self) - self.edit.setPlaceholderText("Type to search…") + self.edit.setPlaceholderText(strings._("find_bar_type_to_search")) layout.addWidget(self.edit) - self.case = QCheckBox("Match case", self) + self.case = QCheckBox(strings._("find_bar_match_case"), self) layout.addWidget(self.case) - self.prevBtn = QPushButton("Prev", self) - self.nextBtn = QPushButton("Next", self) + self.prevBtn = QPushButton(strings._("previous"), self) + self.nextBtn = QPushButton(strings._("next"), self) self.closeBtn = QPushButton("✕", self) self.closeBtn.setFlat(True) layout.addWidget(self.prevBtn) @@ -56,11 +62,15 @@ class FindBar(QWidget): self.setVisible(False) - # 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) + # 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) - # Signals + # Signals (connect ONCE) self.edit.returnPressed.connect(self.find_next) self.edit.textChanged.connect(self._update_highlight) self.case.toggled.connect(self._update_highlight) @@ -68,10 +78,17 @@ 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 @@ -155,6 +172,8 @@ 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() @@ -183,4 +202,5 @@ class FindBar(QWidget): self.editor.setExtraSelections(selections) def _clear_highlight(self): - self.editor.setExtraSelections([]) + if self.editor: + self.editor.setExtraSelections([]) diff --git a/bouquin/flow_layout.py b/bouquin/flow_layout.py new file mode 100644 index 0000000..e2a1c5a --- /dev/null +++ b/bouquin/flow_layout.py @@ -0,0 +1,88 @@ +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 diff --git a/bouquin/fonts/NotoSans-Regular.ttf b/bouquin/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..4bac02f Binary files /dev/null and b/bouquin/fonts/NotoSans-Regular.ttf differ diff --git a/bouquin/fonts/NotoSansSymbols2-Regular.ttf b/bouquin/fonts/NotoSansSymbols2-Regular.ttf new file mode 100644 index 0000000..7816268 Binary files /dev/null and b/bouquin/fonts/NotoSansSymbols2-Regular.ttf differ diff --git a/bouquin/fonts/OFL.txt b/bouquin/fonts/OFL.txt new file mode 100644 index 0000000..106e5d8 --- /dev/null +++ b/bouquin/fonts/OFL.txt @@ -0,0 +1,93 @@ +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. diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 0113ba1..f2cdc1c 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -13,35 +13,42 @@ from PySide6.QtWidgets import ( QMessageBox, QTextBrowser, QTabWidget, + QAbstractItemView, ) +from . import strings -def _html_to_text(s: str) -> str: - """Lightweight HTML→text for diff (keeps paragraphs/line breaks).""" - IMG_RE = re.compile(r"(?is)]*>") - STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?") - COMMENT_RE = re.compile(r"", re.S) - BR_RE = re.compile(r"(?i)") - BLOCK_END_RE = re.compile(r"(?i)") - TAG_RE = re.compile(r"<[^>]+>") - MULTINL_RE = re.compile(r"\n{3,}") - s = IMG_RE.sub("[ Image changed - see Preview pane ]", s) - s = STYLE_SCRIPT_RE.sub("", s) - s = COMMENT_RE.sub("", s) - s = BR_RE.sub("\n", s) - s = BLOCK_END_RE.sub("\n", s) - s = TAG_RE.sub("", s) - s = _html.unescape(s) - s = MULTINL_RE.sub("\n\n", s) +def _markdown_to_text(s: str) -> str: + """Convert markdown to plain text for diff comparison.""" + # Remove images + s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s) + # Remove inline code formatting + s = re.sub(r"`([^`]+)`", r"\1", s) + # Remove bold/italic markers + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + s = re.sub(r"__([^_]+)__", r"\1", s) + s = re.sub(r"\*([^*]+)\*", r"\1", s) + s = re.sub(r"_([^_]+)_", r"\1", s) + # Remove strikethrough + s = re.sub(r"~~([^~]+)~~", r"\1", s) + # Remove heading markers + s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE) + # Remove list markers + s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE) + s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE) + # Remove checkbox markers + s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE) return s.strip() -def _colored_unified_diff_html(old_html: str, new_html: str) -> str: +def _colored_unified_diff_html(old_md: str, new_md: str) -> str: """Return HTML with colored unified diff (+ green, - red, context gray).""" - a = _html_to_text(old_html).splitlines() - b = _html_to_text(new_html).splitlines() - ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="") + 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="" + ) lines = [] for line in ud: if line.startswith("+") and not line.startswith("+++"): @@ -65,7 +72,7 @@ class HistoryDialog(QDialog): def __init__(self, db, date_iso: str, parent=None): super().__init__(parent) - self.setWindowTitle(f"History — {date_iso}") + self.setWindowTitle(f"{strings._('history')} — {date_iso}") self._db = db self._date = date_iso self._versions = [] # list[dict] from DB @@ -76,6 +83,7 @@ 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) @@ -86,8 +94,8 @@ class HistoryDialog(QDialog): self.preview.setOpenExternalLinks(True) self.diff = QTextBrowser() self.diff.setOpenExternalLinks(False) - self.tabs.addTab(self.preview, "Preview") - self.tabs.addTab(self.diff, "Diff") + self.tabs.addTab(self.preview, strings._("history_dialog_preview")) + self.tabs.addTab(self.diff, strings._("history_dialog_diff")) self.tabs.setMinimumSize(500, 650) top.addWidget(self.tabs, 2) @@ -96,39 +104,38 @@ class HistoryDialog(QDialog): # Buttons row = QHBoxLayout() row.addStretch(1) - self.btn_revert = QPushButton("Revert to Selected") + self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected")) self.btn_revert.clicked.connect(self._revert) - self.btn_close = QPushButton("Close") + self.btn_delete = QPushButton(strings._("history_dialog_delete")) + self.btn_delete.clicked.connect(self._delete) + self.btn_close = QPushButton(strings._("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 _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}] + # [{id,version_no,created_at,note,is_current}] + self._versions = self._db.list_versions(self._date) + self._current_id = next( (v["id"] for v in self._versions if v["is_current"]), None ) self.list.clear() for v in self._versions: - label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}" + 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}" if v.get("note"): label += f" · {v['note']}" if v["is_current"]: - label += " **(current)**" + label += " **(" + strings._("current") + ")**" it = QListWidgetItem(label) it.setData(Qt.UserRole, v["id"]) self.list.addItem(it) @@ -143,27 +150,28 @@ class HistoryDialog(QDialog): @Slot() def _on_select(self): + selected_items = self.list.selectedItems() item = self.list.currentItem() - if not item: + if not item or len(selected_items) > 1: self.preview.clear() self.diff.clear() self.btn_revert.setEnabled(False) return + sel_id = item.data(Qt.UserRole) - # Preview selected as HTML sel = self._db.get_version(version_id=sel_id) - self.preview.setHtml(sel["content"]) + self.preview.setMarkdown(sel["content"]) # 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 only if selecting a non-current version + + # Enable revert and delete buttons 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 @@ -171,6 +179,24 @@ class HistoryDialog(QDialog): try: self._db.revert_to_version(self._date, version_id=sel_id) except Exception as e: - QMessageBox.critical(self, "Revert failed", str(e)) + QMessageBox.critical( + self, strings._("history_dialog_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() diff --git a/bouquin/icons/bouquin.svg b/bouquin/icons/bouquin.svg new file mode 100644 index 0000000..b282050 --- /dev/null +++ b/bouquin/icons/bouquin.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index bef0571..195599f 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -1,47 +1,107 @@ 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 = "Enter key", - message: str = "Enter key", + 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, ): """ 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)) - self.edit = QLineEdit() - self.edit.setEchoMode(QLineEdit.Password) - v.addWidget(self.edit) - toggle = QPushButton("Show") + + # 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")) toggle.setCheckable(True) toggle.toggled.connect( - lambda c: self.edit.setEchoMode( + lambda c: self.key_entry.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.edit.text() + 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 diff --git a/bouquin/keys/mig5.asc b/bouquin/keys/mig5.asc new file mode 100644 index 0000000..81d5fc7 --- /dev/null +++ b/bouquin/keys/mig5.asc @@ -0,0 +1,109 @@ +-----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----- diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json new file mode 100644 index 0000000..eb5dd83 --- /dev/null +++ b/bouquin/locales/en.json @@ -0,0 +1,293 @@ +{ + "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" +} diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json new file mode 100644 index 0000000..3ba5ba6 --- /dev/null +++ b/bouquin/locales/fr.json @@ -0,0 +1,290 @@ +{ + "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" +} diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json new file mode 100644 index 0000000..6be0955 --- /dev/null +++ b/bouquin/locales/it.json @@ -0,0 +1,161 @@ +{ + "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" +} diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index 5d7d40a..4a1a98e 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -1,12 +1,14 @@ 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): + def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager): """ Widget that 'locks' the screen after a configured idle time. """ @@ -16,18 +18,16 @@ 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("Locked due to inactivity", self) + msg = QLabel(strings._("lock_overlay_locked"), self) msg.setObjectName("lockLabel") msg.setAlignment(Qt.AlignCenter) - self._btn = QPushButton("Unlock", self) + self._btn = QPushButton(strings._("lock_overlay_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,91 +38,9 @@ class LockOverlay(QWidget): lay.addWidget(self._btn, 0, Qt.AlignCenter) lay.addStretch(1) - self._apply_overlay_style() + themes.register_lock_overlay(self) 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()) diff --git a/bouquin/main.py b/bouquin/main.py index a481480..958185d 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -1,17 +1,25 @@ 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") @@ -19,6 +27,7 @@ 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()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 27934d4..0e5e454 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -15,6 +15,8 @@ from PySide6.QtCore import ( QUrl, QEvent, QSignalBlocker, + QDateTime, + QTime, ) from PySide6.QtGui import ( QAction, @@ -25,36 +27,48 @@ from PySide6.QtGui import ( QFont, QGuiApplication, QKeySequence, - QPalette, QTextCharFormat, QTextCursor, QTextListFormat, ) from PySide6.QtWidgets import ( - QApplication, QCalendarWidget, QDialog, QFileDialog, QMainWindow, + QMenu, QMessageBox, QSizePolicy, QSplitter, + QTableView, + QTabWidget, QVBoxLayout, QWidget, + QLabel, + QPushButton, + QApplication, ) +from .bug_report_dialog import BugReportDialog from .db import DBManager -from .editor import Editor from .find_bar import FindBar from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt from .lock_overlay import LockOverlay +from .markdown_editor import MarkdownEditor +from .pomodoro_timer import PomodoroManager +from .reminders import UpcomingRemindersWidget from .save_dialog import SaveDialog from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog +from .statistics_dialog import StatisticsDialog +from . import strings +from .tags_widget import PageTagsWidget +from .theme import ThemeManager +from .time_log import TimeLogWidget from .toolbar import ToolBar -from .theme import Theme, ThemeManager +from .version_check import VersionChecker class MainWindow(QMainWindow): @@ -64,6 +78,7 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 650) self.themes = themes # Store the themes manager + self.version_checker = VersionChecker(self) self.cfg = load_db_config() if not os.path.exists(self.cfg.path): @@ -79,16 +94,31 @@ class MainWindow(QMainWindow): else: self._try_connect() + self.settings = QSettings(APP_ORG, APP_NAME) + # ---- UI: Left fixed panel (calendar) + right editor ----------------- self.calendar = QCalendarWidget() self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.calendar.setGridVisible(True) self.calendar.selectionChanged.connect(self._on_date_changed) + self.themes.register_calendar(self.calendar) self.search = Search(self.db) self.search.openDateRequested.connect(self._load_selected_date) self.search.resultDatesChanged.connect(self._on_search_dates_changed) + # Features + self.time_log = TimeLogWidget(self.db) + + self.tags = PageTagsWidget(self.db) + self.tags.tagActivated.connect(self._on_tag_activated) + self.tags.tagAdded.connect(self._on_tag_added) + + self.upcoming_reminders = UpcomingRemindersWidget(self.db) + self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) + + self.pomodoro_manager = PomodoroManager(self.db, self) + # Lock the calendar to the left panel at the top to stop it stretching # when the main window is resized. left_panel = QWidget() @@ -96,36 +126,44 @@ class MainWindow(QMainWindow): left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar) left_layout.addWidget(self.search) + left_layout.addWidget(self.upcoming_reminders) + left_layout.addWidget(self.time_log) + left_layout.addWidget(self.tags) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) - # This is the note-taking editor - self.editor = Editor(self.themes) + # Create tab widget to hold multiple editors + self.tab_widget = QTabWidget() + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self._close_tab) + self.tab_widget.currentChanged.connect(self._on_tab_changed) + self._prev_editor = None # Toolbar for controlling styling self.toolBar = ToolBar() self.addToolBar(self.toolBar) - # Wire toolbar intents to editor methods - self.toolBar.boldRequested.connect(self.editor.apply_weight) - self.toolBar.italicRequested.connect(self.editor.apply_italic) - self.toolBar.underlineRequested.connect(self.editor.apply_underline) - self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough) - self.toolBar.codeRequested.connect(self.editor.apply_code) - self.toolBar.headingRequested.connect(self.editor.apply_heading) - self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) - self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) - self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes) - self.toolBar.alignRequested.connect(self.editor.setAlignment) - self.toolBar.historyRequested.connect(self._open_history) - self.toolBar.insertImageRequested.connect(self._on_insert_image) + self._bind_toolbar() - self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) - self.editor.cursorPositionChanged.connect(self._sync_toolbar) + # Create the first editor tab + self._create_new_tab() + self._prev_editor = self.editor split = QSplitter() split.addWidget(left_panel) - split.addWidget(self.editor) + split.addWidget(self.tab_widget) split.setStretchFactor(1, 1) + # Enable context menu on calendar for opening dates in new tabs + self.calendar.setContextMenuPolicy(Qt.CustomContextMenu) + self.calendar.customContextMenuRequested.connect( + self._show_calendar_context_menu + ) + + # Flag to prevent _on_date_changed when showing context menu + self._showing_context_menu = False + + # Install event filter to catch right-clicks before selectionChanged fires + self.calendar.installEventFilter(self) + container = QWidget() lay = QVBoxLayout(container) lay.addWidget(split) @@ -139,14 +177,14 @@ class MainWindow(QMainWindow): self._idle_timer.start() # full-window overlay that sits on top of the central widget - self._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked) + self._lock_overlay = LockOverlay( + self.centralWidget(), self._on_unlock_clicked, themes=self.themes + ) self.centralWidget().installEventFilter(self._lock_overlay) self._locked = False # reset idle timer on any key press anywhere in the app - from PySide6.QtWidgets import QApplication - QApplication.instance().installEventFilter(self) # Focus on the editor @@ -160,125 +198,180 @@ class MainWindow(QMainWindow): ) # Status bar for feedback - self.statusBar().showMessage("Ready", 800) + self.statusBar().showMessage(strings._("main_window_ready"), 800) # Add findBar and add it to the statusBar - self.findBar = FindBar(self.editor, shortcut_parent=self, parent=self) + # FindBar will get the current editor dynamically via a callable + self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.statusBar().addPermanentWidget(self.findBar) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) # Menu bar (File) mb = self.menuBar() - file_menu = mb.addMenu("&File") - act_save = QAction("&Save a version", self) + file_menu = mb.addMenu("&" + strings._("file")) + act_save = QAction("&" + strings._("main_window_save_a_version"), self) act_save.setShortcut("Ctrl+S") act_save.triggered.connect(lambda: self._save_current(explicit=True)) file_menu.addAction(act_save) - act_history = QAction("History", self) - act_history.setShortcut("Ctrl+H") + act_history = QAction("&" + strings._("history"), self) + act_history.setShortcut("Ctrl+Shift+H") act_history.setShortcutContext(Qt.ApplicationShortcut) act_history.triggered.connect(self._open_history) file_menu.addAction(act_history) - act_settings = QAction("Settin&gs", self) - act_settings.setShortcut("Ctrl+G") + act_settings = QAction(strings._("main_window_settings_accessible_flag"), self) + act_settings.setShortcut("Ctrl+Shift+.") act_settings.triggered.connect(self._open_settings) file_menu.addAction(act_settings) - act_export = QAction("&Export", self) - act_export.setShortcut("Ctrl+E") + act_export = QAction(strings._("export_accessible_flag"), self) + act_export.setShortcut("Ctrl+Shift+E") act_export.triggered.connect(self._export) file_menu.addAction(act_export) - act_backup = QAction("&Backup", self) + act_backup = QAction("&" + strings._("backup"), self) act_backup.setShortcut("Ctrl+Shift+B") act_backup.triggered.connect(self._backup) file_menu.addAction(act_backup) + act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self) + act_stats.setShortcut("Ctrl+Shift+S") + act_stats.triggered.connect(self._open_statistics) + file_menu.addAction(act_stats) + act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self) + act_lock.setShortcut("Ctrl+Shift+L") + act_lock.triggered.connect(self._enter_lock) + file_menu.addAction(act_lock) file_menu.addSeparator() - act_quit = QAction("&Quit", self) + act_quit = QAction("&" + strings._("quit"), self) act_quit.setShortcut("Ctrl+Q") act_quit.triggered.connect(self.close) file_menu.addAction(act_quit) # Navigate menu with next/previous/today - nav_menu = mb.addMenu("&Navigate") - act_prev = QAction("Previous Day", self) + nav_menu = mb.addMenu("&" + strings._("navigate")) + act_prev = QAction(strings._("previous_day"), self) act_prev.setShortcut("Ctrl+Shift+P") act_prev.setShortcutContext(Qt.ApplicationShortcut) act_prev.triggered.connect(lambda: self._adjust_day(-1)) nav_menu.addAction(act_prev) self.addAction(act_prev) - act_next = QAction("Next Day", self) + act_next = QAction(strings._("next_day"), self) act_next.setShortcut("Ctrl+Shift+N") act_next.setShortcutContext(Qt.ApplicationShortcut) act_next.triggered.connect(lambda: self._adjust_day(1)) nav_menu.addAction(act_next) self.addAction(act_next) - act_today = QAction("Today", self) + act_today = QAction(strings._("today"), self) act_today.setShortcut("Ctrl+Shift+T") act_today.setShortcutContext(Qt.ApplicationShortcut) act_today.triggered.connect(self._adjust_today) nav_menu.addAction(act_today) self.addAction(act_today) - act_find = QAction("Find on page", self) + act_close_tab = QAction(strings._("close_tab"), self) + act_close_tab.setShortcut("Ctrl+W") + act_close_tab.setShortcutContext(Qt.ApplicationShortcut) + act_close_tab.triggered.connect(self._close_current_tab) + nav_menu.addAction(act_close_tab) + self.addAction(act_close_tab) + + act_find = QAction(strings._("find_on_page"), self) act_find.setShortcut(QKeySequence.Find) act_find.triggered.connect(self.findBar.show_bar) nav_menu.addAction(act_find) self.addAction(act_find) - act_find_next = QAction("Find Next", self) + act_find_next = QAction(strings._("find_next"), self) act_find_next.setShortcut(QKeySequence.FindNext) act_find_next.triggered.connect(self.findBar.find_next) nav_menu.addAction(act_find_next) self.addAction(act_find_next) - act_find_prev = QAction("Find Previous", self) + act_find_prev = QAction(strings._("find_previous"), self) act_find_prev.setShortcut(QKeySequence.FindPrevious) act_find_prev.triggered.connect(self.findBar.find_prev) nav_menu.addAction(act_find_prev) self.addAction(act_find_prev) # Help menu with drop-down - help_menu = mb.addMenu("&Help") - act_docs = QAction("Documentation", self) - act_docs.setShortcut("Ctrl+D") + help_menu = mb.addMenu("&" + strings._("help")) + act_docs = QAction(strings._("documentation"), self) + act_docs.setShortcut("Ctrl+Shift+D") act_docs.setShortcutContext(Qt.ApplicationShortcut) act_docs.triggered.connect(self._open_docs) help_menu.addAction(act_docs) self.addAction(act_docs) - act_bugs = QAction("Report a bug", self) - act_bugs.setShortcut("Ctrl+R") + act_bugs = QAction(strings._("report_a_bug"), self) + act_bugs.setShortcut("Ctrl+Shift+R") act_bugs.setShortcutContext(Qt.ApplicationShortcut) act_bugs.triggered.connect(self._open_bugs) help_menu.addAction(act_bugs) self.addAction(act_bugs) + act_version = QAction(strings._("version"), self) + act_version.setShortcut("Ctrl+Shift+V") + act_version.setShortcutContext(Qt.ApplicationShortcut) + act_version.triggered.connect(self._open_version) + help_menu.addAction(act_version) + self.addAction(act_version) # Autosave self._dirty = False self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) self._save_timer.timeout.connect(self._save_current) - self.editor.textChanged.connect(self._on_text_changed) + + # Reminders / alarms + self._reminder_timers: list[QTimer] = [] # First load + mark dates in calendar with content - if not self._load_yesterday_todos(): + if not self._load_unchecked_todos(): self._load_selected_date() self._refresh_calendar_marks() + # Hide tags and time log widgets if not enabled + if not self.cfg.tags: + self.tags.hide() + if not self.cfg.time_log: + self.time_log.hide() + self.toolBar.actTimer.setVisible(False) + if not self.cfg.reminders: + self.upcoming_reminders.hide() + self.toolBar.actAlarm.setVisible(False) + # Restore window position from settings - self.settings = QSettings(APP_ORG, APP_NAME) self._restore_window_position() - self._apply_link_css() # Apply link color on startup # re-apply all runtime color tweaks when theme changes self.themes.themeChanged.connect(lambda _t: self._retheme_overrides()) - self.themes.themeChanged.connect(self._apply_calendar_theme) self._apply_calendar_text_colors() - self._apply_calendar_theme(self.themes.current()) # apply once on startup so links / calendar colors are set immediately self._retheme_overrides() + # Build any alarms for *today* from stored markdown + self._rebuild_reminders_for_today() + + # Rollover unchecked todos automatically when the calendar day changes + self._day_change_timer = QTimer(self) + self._day_change_timer.setSingleShot(True) + self._day_change_timer.timeout.connect(self._on_day_changed) + self._schedule_next_day_change() + + # Ensure toolbar is definitely visible + self.toolBar.setVisible(True) + + @property + def editor(self) -> MarkdownEditor | None: + """Get the currently active editor.""" + return self.tab_widget.currentWidget() + + def _call_editor(self, method_name, *args): + """ + Call the relevant method of the MarkdownEditor class on bind + """ + getattr(self.editor, method_name)(*args) + + # ----------- Database connection/key management methods ------------ # + def _try_connect(self) -> bool: """ Try to connect to the database. @@ -286,105 +379,652 @@ class MainWindow(QMainWindow): try: self.db = DBManager(self.cfg) ok = self.db.connect() + return ok except Exception as e: if str(e) == "file is not a database": - error = "The key is probably incorrect." + error = strings._("db_key_incorrect") else: error = str(e) - QMessageBox.critical(self, "Database Error", error) + QMessageBox.critical(self, strings._("db_database_error"), error) return False - return ok def _prompt_for_key_until_valid(self, first_time: bool) -> bool: """ Prompt for the SQLCipher key. """ if first_time: - title = "Set an encryption key" - message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!" + title = strings._("set_an_encryption_key") + message = strings._("set_an_encryption_key_explanation") else: - title = "Unlock encrypted notebook" - message = "Enter your key to unlock the notebook" + title = strings._("unlock_encrypted_notebook") + message = strings._("unlock_encrypted_notebook_explanation") while True: - dlg = KeyPrompt(self, title, message) + dlg = KeyPrompt( + self, title, message, initial_db_path=self.cfg.path, show_db_change=True + ) if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() + + # Update DB path if the user changed it + new_path = dlg.db_path() + if new_path is not None and new_path != self.cfg.path: + self.cfg.path = new_path + # Persist immediately so next run is pre-filled with this file + save_db_config(self.cfg) + if self._try_connect(): return True + # ----------------- Tab and date management ----------------- # + + def _current_date_iso(self) -> str: + d = self.calendar.selectedDate() + return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" + + def _date_key(self, qd: QDate) -> tuple[int, int, int]: + return (qd.year(), qd.month(), qd.day()) + + def _index_for_date_insert(self, date: QDate) -> int: + """Return the index where a tab for `date` should be inserted (ascending order).""" + key = self._date_key(date) + for i in range(self.tab_widget.count()): + w = self.tab_widget.widget(i) + d = getattr(w, "current_date", None) + if isinstance(d, QDate) and d.isValid(): + if self._date_key(d) > key: + return i + return self.tab_widget.count() + + def _reorder_tabs_by_date(self): + """Reorder existing tabs by their date (ascending).""" + bar = self.tab_widget.tabBar() + dated, undated = [], [] + + for i in range(self.tab_widget.count()): + w = self.tab_widget.widget(i) + d = getattr(w, "current_date", None) + if isinstance(d, QDate) and d.isValid(): + dated.append((d, w)) + else: + undated.append(w) + + dated.sort(key=lambda t: self._date_key(t[0])) + + with QSignalBlocker(self.tab_widget): + # Update labels to yyyy-MM-dd + for d, w in dated: + idx = self.tab_widget.indexOf(w) + if idx != -1: + self.tab_widget.setTabText(idx, d.toString("yyyy-MM-dd")) + + # Move dated tabs into target positions 0..len(dated)-1 + for target_pos, (_, w) in enumerate(dated): + cur = self.tab_widget.indexOf(w) + if cur != -1 and cur != target_pos: + bar.moveTab(cur, target_pos) + + # Keep any undated pages (if they ever exist) after the dated ones + start = len(dated) + for offset, w in enumerate(undated): + cur = self.tab_widget.indexOf(w) + target = start + offset + if cur != -1 and cur != target: + bar.moveTab(cur, target) + + def _tab_index_for_date(self, date: QDate) -> int: + """Return the index of the tab showing `date`, or -1 if none.""" + iso = date.toString("yyyy-MM-dd") + for i in range(self.tab_widget.count()): + w = self.tab_widget.widget(i) + if ( + hasattr(w, "current_date") + and w.current_date.toString("yyyy-MM-dd") == iso + ): + return i + return -1 + + def _open_date_in_tab(self, date: QDate): + """Focus existing tab for `date`, or create it if needed. Returns the editor.""" + idx = self._tab_index_for_date(date) + if idx != -1: + self.tab_widget.setCurrentIndex(idx) + # keep calendar selection in sync (don’t trigger load) + from PySide6.QtCore import QSignalBlocker + + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(date) + QTimer.singleShot(0, self._focus_editor_now) + return self.tab_widget.widget(idx) + # not open yet -> create + return self._create_new_tab(date) + + def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor: + """Create a new editor tab and return the editor instance.""" + if date is None: + date = self.calendar.selectedDate() + + # Deduplicate: if already open, just jump there + existing = self._tab_index_for_date(date) + if existing != -1: + self.tab_widget.setCurrentIndex(existing) + return self.tab_widget.widget(existing) + + editor = MarkdownEditor(self.themes) + + # Apply user’s preferred font size + self._apply_font_size(editor) + + # Set up the editor's event connections + editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) + editor.cursorPositionChanged.connect(self._sync_toolbar) + editor.textChanged.connect(self._on_text_changed) + + # Set tab title + tab_title = date.toString("yyyy-MM-dd") + + # Add the tab + index = self.tab_widget.addTab(editor, tab_title) + self.tab_widget.setCurrentIndex(index) + + # Load the date's content + self._load_date_into_editor(date) + + # Store the date with the editor so we can save it later + editor.current_date = date + + # Insert at sorted position + tab_title = date.toString("yyyy-MM-dd") + pos = self._index_for_date_insert(date) + index = self.tab_widget.insertTab(pos, editor, tab_title) + self.tab_widget.setCurrentIndex(index) + + return editor + + def _close_tab(self, index: int): + """Close a tab at the given index.""" + if self.tab_widget.count() <= 1: + # Don't close the last tab + return + + editor = self.tab_widget.widget(index) + if editor: + # Save before closing + self._save_editor_content(editor) + self._dirty = False + + self.tab_widget.removeTab(index) + + def _close_current_tab(self): + """Close the currently active tab via shortcuts (Ctrl+W).""" + idx = self.tab_widget.currentIndex() + if idx >= 0: + self._close_tab(idx) + + def _on_tab_changed(self, index: int): + """Handle tab change - reconnect toolbar and sync UI.""" + if index < 0: + return + + # If we had pending edits, flush them from the tab we're leaving. + try: + self._save_timer.stop() # avoid a pending autosave targeting the *new* tab + except Exception: + pass + + if getattr(self, "_prev_editor", None) is not None and self._dirty: + self._save_editor_content(self._prev_editor) + self._dirty = False # we just saved the edited tab + + # Update calendar selection to match the tab + editor = self.tab_widget.widget(index) + if editor and hasattr(editor, "current_date"): + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(editor.current_date) + + # update per-page tags for the active tab + date_iso = editor.current_date.toString("yyyy-MM-dd") + self._update_tag_views_for_date(date_iso) + + # Reconnect toolbar to new active editor + self._sync_toolbar() + + # Focus the editor + QTimer.singleShot(0, self._focus_editor_now) + + # Remember this as the "previous" editor for next switch + self._prev_editor = editor + + def _date_from_calendar_pos(self, pos) -> QDate | None: + """Translate a QCalendarWidget local pos to the QDate under the cursor.""" + view: QTableView = self.calendar.findChild( + QTableView, "qt_calendar_calendarview" + ) + if view is None: + return None + + # Map calendar-local pos -> viewport pos + vp_pos = view.viewport().mapFrom(self.calendar, pos) + idx = view.indexAt(vp_pos) + if not idx.isValid(): + return None + + model = view.model() + + # Account for optional headers + start_col = ( + 0 + if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader + else 1 + ) + start_row = ( + 0 + if self.calendar.horizontalHeaderFormat() + == QCalendarWidget.NoHorizontalHeader + else 1 + ) + + # Find index of day 1 (first cell belonging to current month) + first_index = None + for r in range(start_row, model.rowCount()): + for c in range(start_col, model.columnCount()): + if model.index(r, c).data() == 1: + first_index = model.index(r, c) + break + if first_index: + break + if first_index is None: + return None + + # Find index of the last day of the current month + last_day = ( + QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1) + .addMonths(1) + .addDays(-1) + .day() + ) + last_index = None + for r in range(model.rowCount() - 1, first_index.row() - 1, -1): + for c in range(model.columnCount() - 1, start_col - 1, -1): + if model.index(r, c).data() == last_day: + last_index = model.index(r, c) + break + if last_index: + break + if last_index is None: + return None + + # Determine if clicked cell belongs to prev/next month or current + day = int(idx.data()) + year = self.calendar.yearShown() + month = self.calendar.monthShown() + + before_first = (idx.row() < first_index.row()) or ( + idx.row() == first_index.row() and idx.column() < first_index.column() + ) + after_last = (idx.row() > last_index.row()) or ( + idx.row() == last_index.row() and idx.column() > last_index.column() + ) + + if before_first: + if month == 1: + month = 12 + year -= 1 + else: + month -= 1 + elif after_last: + if month == 12: + month = 1 + year += 1 + else: + month += 1 + + qd = QDate(year, month, day) + return qd if qd.isValid() else None + + def _show_calendar_context_menu(self, pos): + self._showing_context_menu = True # so selectionChanged handler doesn't fire + clicked_date = self._date_from_calendar_pos(pos) + + menu = QMenu(self) + open_in_new_tab_action = menu.addAction(strings._("open_in_new_tab")) + action = menu.exec_(self.calendar.mapToGlobal(pos)) + + self._showing_context_menu = False + + if action == open_in_new_tab_action and clicked_date and clicked_date.isValid(): + self._open_date_in_tab(clicked_date) + + def _load_selected_date(self, date_iso=False, extra_data=False): + """Load a date into the current editor""" + if not date_iso: + date_iso = self._current_date_iso() + + qd = QDate.fromString(date_iso, "yyyy-MM-dd") + current_index = self.tab_widget.currentIndex() + + # Check if this date is already open in a *different* tab + existing_idx = self._tab_index_for_date(qd) + if existing_idx != -1 and existing_idx != current_index: + # Date is already open in another tab - just switch to that tab + self.tab_widget.setCurrentIndex(existing_idx) + # Keep calendar in sync + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(qd) + QTimer.singleShot(0, self._focus_editor_now) + return + + # Date not open in any other tab - load it into current tab + # Keep calendar in sync + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(qd) + + self._load_date_into_editor(qd, extra_data) + self.editor.current_date = qd + + # Update tab title + if current_index >= 0: + self.tab_widget.setTabText(current_index, date_iso) + + # Keep tabs sorted by date + self._reorder_tabs_by_date() + + # sync tags + self._update_tag_views_for_date(date_iso) + + def _load_date_into_editor(self, date: QDate, extra_data=False): + """Load a specific date's content into a given editor.""" + date_iso = date.toString("yyyy-MM-dd") + text = self.db.get_entry(date_iso) + if extra_data: + # Append extra data as markdown + if text and not text.endswith("\n"): + text += "\n" + text += extra_data + # Force a save now so we don't lose it. + self._set_editor_markdown_preserve_view(text) + self._dirty = True + self._save_date(date_iso, True) + + self._set_editor_markdown_preserve_view(text) + self._dirty = False + + def _set_editor_markdown_preserve_view(self, markdown: str): + + # Save caret/selection and scroll + cur = self.editor.textCursor() + old_pos, old_anchor = cur.position(), cur.anchor() + v = self.editor.verticalScrollBar().value() + h = self.editor.horizontalScrollBar().value() + + # Only touch the doc if it actually changed + self.editor.blockSignals(True) + if self.editor.to_markdown() != markdown: + self.editor.from_markdown(markdown) + self.editor.blockSignals(False) + + # Restore scroll first + self.editor.verticalScrollBar().setValue(v) + self.editor.horizontalScrollBar().setValue(h) + + # Restore caret/selection (bounded to new doc length) + doc_length = self.editor.document().characterCount() - 1 + old_pos = min(old_pos, doc_length) + old_anchor = min(old_anchor, doc_length) + + cur = self.editor.textCursor() + cur.setPosition(old_anchor) + mode = ( + QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor + ) + cur.setPosition(old_pos, mode) + self.editor.setTextCursor(cur) + + # Refresh highlights if the theme changed + if hasattr(self, "findBar"): + self.findBar.refresh() + + def _save_editor_content(self, editor: MarkdownEditor): + """Save a specific editor's content to its associated date.""" + # Skip if DB is missing or not connected somehow. + if not getattr(self, "db", None) or getattr(self.db, "conn", None) is None: + return + if not hasattr(editor, "current_date"): + return + date_iso = editor.current_date.toString("yyyy-MM-dd") + md = editor.to_markdown() + self.db.save_new_version(date_iso, md, note=strings._("autosave")) + + def _on_text_changed(self): + self._dirty = True + self._save_timer.start(5000) # autosave after idle + + def _adjust_day(self, delta: int): + """Move selection by delta days (negative for previous).""" + d = self.calendar.selectedDate().addDays(delta) + self.calendar.setSelectedDate(d) + + def _adjust_today(self): + """Jump to today.""" + today = QDate.currentDate() + self._create_new_tab(today) + + def _rollover_target_date(self, day: QDate) -> QDate: + """ + Given a 'new day' (system date), return the date we should move + unfinished todos *to*. + + If the new day is Saturday or Sunday, we skip ahead to the next Monday. + Otherwise we just return the same day. + """ + # Qt: Monday=1 ... Sunday=7 + dow = day.dayOfWeek() + if dow >= 6: # Saturday (6) or Sunday (7) + return day.addDays(8 - dow) # 6 -> +2, 7 -> +1 (next Monday) + return day + + def _schedule_next_day_change(self) -> None: + """ + Schedule a one-shot timer to fire shortly after the next midnight. + """ + now = QDateTime.currentDateTime() + tomorrow = now.date().addDays(1) + # A couple of minutes after midnight to be safe + next_run = QDateTime(tomorrow, QTime(0, 2)) + msecs = max(60_000, now.msecsTo(next_run)) # at least 1 minute + self._day_change_timer.start(msecs) + + @Slot() + def _on_day_changed(self) -> None: + """ + Called when we've crossed into a new calendar day (according to the timer). + Re-runs the rollover logic and refreshes the UI. + """ + # Make the calendar show the *real* new day first + today = QDate.currentDate() + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(today) + + # Same logic as on startup + if not self._load_unchecked_todos(): + self._load_selected_date() + + self._refresh_calendar_marks() + self._rebuild_reminders_for_today() + self._schedule_next_day_change() + + def _load_unchecked_todos(self, days_back: int = 7) -> bool: + """ + Move unchecked checkbox items from the last `days_back` days + into the rollover target date (today, or next Monday if today + is a weekend). + + Returns True if any items were moved, False otherwise. + """ + if not getattr(self.cfg, "move_todos", False): + return False + + if not getattr(self, "db", None): + return False + + today = QDate.currentDate() + target_date = self._rollover_target_date(today) + target_iso = target_date.toString("yyyy-MM-dd") + + all_unchecked: list[str] = [] + any_moved = False + + # Look back N days (yesterday = 1, up to `days_back`) + for delta in range(1, days_back + 1): + src_date = today.addDays(-delta) + src_iso = src_date.toString("yyyy-MM-dd") + text = self.db.get_entry(src_iso) + if not text: + continue + + lines = text.split("\n") + remaining_lines: list[str] = [] + moved_from_this_day = False + + for line in lines: + # Unchecked markdown checkboxes: "- [ ] " or "- [☐] " + if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( + r"^\s*-\s*\[☐\]\s+", line + ): + item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) + all_unchecked.append(f"- [ ] {item_text}") + moved_from_this_day = True + any_moved = True + else: + remaining_lines.append(line) + + if moved_from_this_day: + modified_text = "\n".join(remaining_lines) + # Save the cleaned-up source day + self.db.save_new_version( + src_iso, + modified_text, + strings._("unchecked_checkbox_items_moved_to_next_day"), + ) + + if not any_moved: + return False + + # Append everything we collected to the *target* date + unchecked_str = "\n".join(all_unchecked) + "\n" + self._load_selected_date(target_iso, unchecked_str) + return True + + def _on_date_changed(self): + """ + When the calendar selection changes, save the previous day's note if dirty, + so we don't lose that text, then load the newly selected day into current tab. + """ + # Skip if we're showing a context menu (right-click shouldn't load dates) + if getattr(self, "_showing_context_menu", False): + return + + # Stop pending autosave and persist current buffer if needed + try: + self._save_timer.stop() + except Exception: + pass + + # Save the current editor's content if dirty + if hasattr(self.editor, "current_date") and self._dirty: + prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd") + self._save_date(prev_date_iso, explicit=False) + + # Now load the newly selected date + new_date = self.calendar.selectedDate() + current_index = self.tab_widget.currentIndex() + + # Check if this date is already open in a *different* tab + existing_idx = self._tab_index_for_date(new_date) + if existing_idx != -1 and existing_idx != current_index: + # Date is already open in another tab - just switch to that tab + self.tab_widget.setCurrentIndex(existing_idx) + QTimer.singleShot(0, self._focus_editor_now) + return + + # Date not open in any other tab - load it into current tab + self._load_date_into_editor(new_date) + self.editor.current_date = new_date + + # Update tab title + if current_index >= 0: + self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd")) + + # Update tags for the newly loaded page + date_iso = new_date.toString("yyyy-MM-dd") + self._update_tag_views_for_date(date_iso) + + # Keep tabs sorted by date + self._reorder_tabs_by_date() + + def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"): + """ + Save editor contents into the given date. Shows status on success. + explicit=True means user invoked Save: show feedback even if nothing changed. + """ + # Bail out if there is no DB connection (can happen during construction/teardown) + if not getattr(self.db, "conn", None): + return + + if not self._dirty and not explicit: + return + text = self.editor.to_markdown() if hasattr(self, "editor") else "" + self.db.save_new_version(date_iso, text, note) + self._dirty = False + self._refresh_calendar_marks() + # Feedback in the status bar + from datetime import datetime as _dt + + self.statusBar().showMessage( + strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", 2000 + ) + + def _save_current(self, explicit: bool = False): + """Save the current editor's content.""" + try: + self._save_timer.stop() + except Exception: + pass + + if explicit: + # Prompt for a note + dlg = SaveDialog(self) + if dlg.exec() != QDialog.Accepted: + return + note = dlg.note_text() + else: + note = strings._("autosave") + # Save the current editor's date + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + self._save_date(date_iso, explicit, note) + try: + self._save_timer.start() + except Exception: + pass + + # ----------------- Some theme helpers -------------------# + def _apply_font_size(self, editor: MarkdownEditor) -> None: + """Apply the saved font size to a newly created editor.""" + size = self.cfg.font_size + editor.qfont.setPointSize(size) + editor.setFont(editor.qfont) + self.cfg.font_size = size + # save size to settings + cfg = load_db_config() + cfg.font_size = self.cfg.font_size + save_db_config(cfg) + def _retheme_overrides(self): - if hasattr(self, "_lock_overlay"): - self._lock_overlay._apply_overlay_style() self._apply_calendar_text_colors() - self._apply_link_css() self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self.calendar.update() self.editor.viewport().update() - def _apply_link_css(self): - if self.themes and self.themes.current() == Theme.DARK: - anchor = Theme.ORANGE_ANCHOR.value - visited = Theme.ORANGE_ANCHOR_VISITED.value - css = f""" - a {{ color: {anchor}; text-decoration: underline; }} - a:visited {{ color: {visited}; }} - """ - else: - css = "" # Default to no custom styling for links (system or light theme) - - try: - self.editor.document().setDefaultStyleSheet(css) - except Exception: - pass - - try: - self.search.document().setDefaultStyleSheet(css) - except Exception: - pass - - def _apply_calendar_theme(self, theme: Theme): - """Use orange accents on the calendar in dark mode only.""" - app_pal = QApplication.instance().palette() - - if theme == Theme.DARK: - highlight = QColor(Theme.ORANGE_ANCHOR.value) - black = QColor(0, 0, 0) - - highlight_css = Theme.ORANGE_ANCHOR.value - - # Per-widget palette: selection color inside the date grid - pal = self.calendar.palette() - pal.setColor(QPalette.Highlight, highlight) - pal.setColor(QPalette.HighlightedText, black) - self.calendar.setPalette(pal) - - # Stylesheet: nav bar + selected-day background - self.calendar.setStyleSheet( - 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; - }} - /* Optional: keep weekday header readable */ - QCalendarWidget QTableView QHeaderView::section {{ - background: transparent; - color: palette(windowText); - }} - """ - ) - else: - # Back to app defaults in light/system - self.calendar.setPalette(app_pal) - self.calendar.setStyleSheet("") - - self._apply_calendar_text_colors() - self.calendar.update() - def _apply_calendar_text_colors(self): pal = self.palette() txt = pal.windowText().color() @@ -394,6 +1034,8 @@ class MainWindow(QMainWindow): self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) + # --------------- Search sidebar/results helpers ---------------- # + def _on_search_dates_changed(self, date_strs: list[str]): dates = set() for ds in date_strs or []: @@ -434,7 +1076,7 @@ class MainWindow(QMainWindow): fmt.setFontWeight(QFont.Weight.Normal) # remove bold only self.calendar.setDateTextFormat(d, fmt) self._marked_dates = set() - try: + if self.db.conn is not None: for date_iso in self.db.dates_with_content(): qd = QDate.fromString(date_iso, "yyyy-MM-dd") if qd.isValid(): @@ -442,25 +1084,56 @@ class MainWindow(QMainWindow): fmt.setFontWeight(QFont.Weight.Bold) # add bold only self.calendar.setDateTextFormat(qd, fmt) self._marked_dates.add(qd) - except Exception: - pass - # --- UI handlers --------------------------------------------------------- + # -------------------- UI handlers ------------------- # + + def _bind_toolbar(self): + if getattr(self, "_toolbar_bound", False): + return + tb = self.toolBar + + # keep refs so we never create new lambdas (prevents accidental dupes) + self._tb_bold = lambda: self._call_editor("apply_weight") + self._tb_italic = lambda: self._call_editor("apply_italic") + self._tb_strike = lambda: self._call_editor("apply_strikethrough") + self._tb_code = lambda: self._call_editor("apply_code") + self._tb_heading = lambda level: self._call_editor("apply_heading", level) + self._tb_bullets = lambda: self._call_editor("toggle_bullets") + self._tb_numbers = lambda: self._call_editor("toggle_numbers") + self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes") + self._tb_alarm = self._on_alarm_requested + self._tb_timer = self._on_timer_requested + self._tb_font_larger = self._on_font_larger_requested + self._tb_font_smaller = self._on_font_smaller_requested + + tb.boldRequested.connect(self._tb_bold) + tb.italicRequested.connect(self._tb_italic) + tb.strikeRequested.connect(self._tb_strike) + tb.codeRequested.connect(self._tb_code) + tb.headingRequested.connect(self._tb_heading) + tb.bulletsRequested.connect(self._tb_bullets) + tb.numbersRequested.connect(self._tb_numbers) + tb.checkboxesRequested.connect(self._tb_checkboxes) + tb.alarmRequested.connect(self._tb_alarm) + tb.timerRequested.connect(self._tb_timer) + tb.insertImageRequested.connect(self._on_insert_image) + tb.historyRequested.connect(self._open_history) + tb.fontSizeLargerRequested.connect(self._tb_font_larger) + tb.fontSizeSmallerRequested.connect(self._tb_font_smaller) + + self._toolbar_bound = True def _sync_toolbar(self): fmt = self.editor.currentCharFormat() c = self.editor.textCursor() - bf = c.blockFormat() # Block signals so setChecked() doesn't re-trigger actions QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) - QSignalBlocker(self.toolBar.actUnderline) QSignalBlocker(self.toolBar.actStrike) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actItalic.setChecked(fmt.fontItalic()) - self.toolBar.actUnderline.setChecked(fmt.fontUnderline()) self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) # Headings: decide which to check by current point size @@ -492,189 +1165,254 @@ class MainWindow(QMainWindow): self.toolBar.actBullets.setChecked(bool(bullets_on)) self.toolBar.actNumbers.setChecked(bool(numbers_on)) - # Alignment - align = bf.alignment() & Qt.AlignHorizontal_Mask - QSignalBlocker(self.toolBar.actAlignL) - self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft) - QSignalBlocker(self.toolBar.actAlignC) - self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter) - QSignalBlocker(self.toolBar.actAlignR) - self.toolBar.actAlignR.setChecked(align == Qt.AlignRight) + def _change_font_size(self, delta: int) -> None: + """Change font size for all editor tabs and save the setting.""" + old_size = self.cfg.font_size + new_size = old_size + delta - def _current_date_iso(self) -> str: - d = self.calendar.selectedDate() - return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" + self.cfg.font_size = new_size + # save size to settings + cfg = load_db_config() + cfg.font_size = self.cfg.font_size + save_db_config(cfg) - def _load_selected_date(self, date_iso=False, extra_data=False): - if not date_iso: - date_iso = self._current_date_iso() - try: - text = self.db.get_entry(date_iso) - if extra_data: - # Wrap extra_data in a

tag for HTML rendering - extra_data_html = f"

{extra_data}

" + # Apply font size change to all open editors + self._apply_font_size_to_all_tabs(new_size) - # Inject the extra_data before the closing - modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) - text = modified - # Force a save now so we don't lose it. - self._set_editor_html_preserve_view(text) - self._dirty = True - self._save_date(date_iso, True) + def _apply_font_size_to_all_tabs(self, size: int) -> None: + for i in range(self.tab_widget.count()): + ed = self.tab_widget.widget(i) + if not isinstance(ed, MarkdownEditor): + continue + ed.qfont.setPointSize(size) + ed.setFont(ed.qfont) - except Exception as e: - QMessageBox.critical(self, "Read Error", str(e)) + def _on_font_larger_requested(self) -> None: + self._change_font_size(+1) + + def _on_font_smaller_requested(self) -> None: + self._change_font_size(-1) + + # ----------- Alarms handler ------------# + def _on_alarm_requested(self): + self.upcoming_reminders._add_reminder() + + def _on_timer_requested(self): + """Start a Pomodoro timer for the current line.""" + editor = getattr(self, "editor", None) + if editor is None: return - self._set_editor_html_preserve_view(text) + # Get the current line text + line_text = editor.get_current_line_text().strip() + if not line_text: + line_text = strings._("pomodoro_time_log_default_text") - self._dirty = False - # track which date the editor currently represents - self._active_date_iso = date_iso - qd = QDate.fromString(date_iso, "yyyy-MM-dd") - self.calendar.setSelectedDate(qd) + # Get current date + date_iso = self.editor.current_date.toString("yyyy-MM-dd") - def _on_text_changed(self): - self._dirty = True - self._save_timer.start(5000) # autosave after idle + # Start the timer + self.pomodoro_manager.start_timer_for_line(line_text, date_iso) - def _adjust_day(self, delta: int): - """Move selection by delta days (negative for previous).""" - d = self.calendar.selectedDate().addDays(delta) - self.calendar.setSelectedDate(d) + def _show_flashing_reminder(self, text: str): + """ + Show a small flashing dialog and request attention from the OS. + Called by reminder timers. + """ + # Ask OS to flash / bounce our app in the dock/taskbar + QApplication.alert(self, 0) - def _adjust_today(self): - """Jump to today.""" - today = QDate.currentDate() - self.calendar.setSelectedDate(today) + # Try to bring the window to the front + self.showNormal() + self.raise_() + self.activateWindow() - def _load_yesterday_todos(self): - try: - if not self.cfg.move_todos: - return - yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") - text = self.db.get_entry(yesterday_str) - unchecked_items = [] + # Simple dialog with a flashing background to reinforce the alert + dlg = QDialog(self) + dlg.setWindowTitle(strings._("reminder")) + dlg.setModal(True) + dlg.setMinimumWidth(400) - # Regex to match the unchecked checkboxes and their associated text - checkbox_pattern = re.compile( - r"]*>(☐)\s*(.*?)

", re.DOTALL - ) + layout = QVBoxLayout(dlg) + label = QLabel(text) + label.setWordWrap(True) + layout.addWidget(label) - # Find unchecked items and store them - for match in checkbox_pattern.finditer(text): - checkbox = match.group(1) # Either ☐ or ☑ - item_text = match.group(2).strip() # The text after the checkbox - if checkbox == "☐": # If it's an unchecked checkbox (☐) - unchecked_items.append("☐ " + item_text) # Store the unchecked item + btn = QPushButton(strings._("dismiss")) + btn.clicked.connect(dlg.accept) + layout.addWidget(btn) - # Remove the unchecked items from yesterday's HTML content - if unchecked_items: - # This regex will find the entire checkbox line and remove it from the HTML content - uncheckbox_pattern = re.compile( - r"]*>☐\s*(.*?)

", re.DOTALL - ) - modified_text = re.sub( - uncheckbox_pattern, "", text - ) # Remove the checkbox lines + flash_timer = QTimer(dlg) + flash_state = {"on": False} - # Save the modified HTML back to the database - self.db.save_new_version( - yesterday_str, - modified_text, - "Unchecked checkbox items moved to next day", - ) - - # Join unchecked items into a formatted string - unchecked_str = "\n".join( - [f"

{item}

" for item in unchecked_items] - ) - - # Load the unchecked items into the current editor - self._load_selected_date(False, unchecked_str) + def toggle(): + flash_state["on"] = not flash_state["on"] + if flash_state["on"]: + dlg.setStyleSheet("background-color: #3B3B3B;") else: - return False + dlg.setStyleSheet("") - except Exception as e: - raise SystemError(e) + flash_timer.timeout.connect(toggle) + flash_timer.start(500) # ms - def _on_date_changed(self): + dlg.exec() + + flash_timer.stop() + + def _clear_reminder_timers(self): + """Stop and delete any existing reminder timers.""" + for t in self._reminder_timers: + try: + t.stop() + t.deleteLater() + except Exception: + pass + self._reminder_timers = [] + + def _rebuild_reminders_for_today(self): """ - When the calendar selection changes, save the previous day's note if dirty, - so we don't lose that text, then load the newly selected day. + Scan the markdown for today's date and create QTimers + only for alarms on the *current day* (system date). """ - # Stop pending autosave and persist current buffer if needed - try: - self._save_timer.stop() - except Exception: - pass - prev = getattr(self, "_active_date_iso", None) - if prev and self._dirty: - self._save_date(prev, explicit=False) - # Now load the newly selected date - self._load_selected_date() + # We only ever set timers for the real current date + today = QDate.currentDate() + today_iso = today.toString("yyyy-MM-dd") - def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"): - """ - Save editor contents into the given date. Shows status on success. - explicit=True means user invoked Save: show feedback even if nothing changed. - """ - if not self._dirty and not explicit: - return - text = self.editor.to_html_with_embedded_images() - try: - self.db.save_new_version(date_iso, text, note) - except Exception as e: - QMessageBox.critical(self, "Save Error", str(e)) - return - self._dirty = False - self._refresh_calendar_marks() - # Feedback in the status bar - from datetime import datetime as _dt + # Clear any previously scheduled "today" reminders + self._clear_reminder_timers() - self.statusBar().showMessage( - f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000 - ) - - def _save_current(self, explicit: bool = False): - try: - self._save_timer.stop() - except Exception: - pass - if explicit: - # Prompt for a note - dlg = SaveDialog(self) - if dlg.exec() != QDialog.Accepted: - return - note = dlg.note_text() + # Prefer live editor content if it is showing today's page + text = "" + if ( + hasattr(self, "editor") + and hasattr(self.editor, "current_date") + and self.editor.current_date == today + ): + text = self.editor.to_markdown() else: - note = "autosave" - # Delegate to _save_date for the currently selected date - self._save_date(self._current_date_iso(), explicit, note) - try: - self._save_timer.start() - except Exception: - pass + # Fallback to DB: still only today's date + text = self.db.get_entry(today_iso) if hasattr(self, "db") else "" + if not text: + return + + now = QDateTime.currentDateTime() + + for line in text.splitlines(): + # Look for "⏰ HH:MM" anywhere in the line + m = re.search(r"⏰\s*(\d{1,2}):(\d{2})", line) + if not m: + continue + + hour = int(m.group(1)) + minute = int(m.group(2)) + + t = QTime(hour, minute) + if not t.isValid(): + continue + + target = QDateTime(today, t) + + # Skip alarms that are already in the past + if target <= now: + continue + + # The reminder text is the part before the symbol + reminder_text = line.split("⏰", 1)[0].strip() + if not reminder_text: + reminder_text = strings._("reminder_no_text_fallback") + + msecs = now.msecsTo(target) + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect( + lambda txt=reminder_text: self._show_flashing_reminder(txt) + ) + timer.start(msecs) + self._reminder_timers.append(timer) + + # ----------- History handler ------------# def _open_history(self): - date_iso = self._current_date_iso() + if hasattr(self.editor, "current_date"): + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + else: + date_iso = self._current_date_iso() + dlg = HistoryDialog(self.db, date_iso, self) if dlg.exec() == QDialog.Accepted: # refresh editor + calendar (head pointer may have changed) self._load_selected_date(date_iso) self._refresh_calendar_marks() + # ----------- Image insert handler ------------# def _on_insert_image(self): # Let the user pick one or many images paths, _ = QFileDialog.getOpenFileNames( self, - "Insert image(s)", + strings._("insert_images"), "", - "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)", + strings._("images") + "(*.png *.jpg *.jpeg *.bmp *.gif *.webp)", ) if not paths: return - self.editor.insert_images(paths) # call into the editor + # Insert each image + for path_str in paths: + self.editor.insert_image_from_path(Path(path_str)) + + # ----------- Tags handler ----------------# + def _update_tag_views_for_date(self, date_iso: str): + if hasattr(self, "tags"): + self.tags.set_current_date(date_iso) + if hasattr(self, "time_log"): + self.time_log.set_current_date(date_iso) + + def _on_tag_added(self): + """Called when a tag is added - trigger autosave for current page""" + # Use QTimer to defer the save slightly, avoiding re-entrancy issues + from PySide6.QtCore import QTimer + + QTimer.singleShot(0, self._do_tag_save) + + def _do_tag_save(self): + """Actually perform the save after tag is added""" + if hasattr(self, "editor") and hasattr(self.editor, "current_date"): + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + + # Get current editor content + text = self.editor.to_markdown() + + # Save the content (or blank if page is empty) + # This ensures the page shows up in tag browser + self.db.save_new_version(date_iso, text, note="Tag added") + self._dirty = False + self._refresh_calendar_marks() + from datetime import datetime as _dt + + self.statusBar().showMessage( + strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", + 2000, + ) + + def _on_tag_activated(self, tag_name_or_date: str): + # If it's a date (YYYY-MM-DD format), load it + if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2: + self._load_selected_date(tag_name_or_date) + else: + # It's a tag name, open the tag browser + from .tag_browser import TagBrowserDialog + + dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date) + dlg.openDateRequested.connect(self._load_selected_date) + dlg.tagsModified.connect(self._refresh_current_page_tags) + dlg.exec() + + def _refresh_current_page_tags(self): + """Refresh the tag chips for the current page (after tag browser changes)""" + if hasattr(self, "tags") and hasattr(self.editor, "current_date"): + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + self.tags.set_current_date(date_iso) + if self.tags.toggle_btn.isChecked(): + self.tags._reload_tags() # ----------- Settings handler ------------# def _open_settings(self): @@ -691,24 +1429,64 @@ class MainWindow(QMainWindow): self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos) + self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) + self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) + self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) + self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) + self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) # Persist once save_db_config(self.cfg) - # Apply idle setting immediately (restart the timer with new interval if it changed) self._apply_idle_minutes(self.cfg.idle_minutes) + # Apply font size to all tabs + self._apply_font_size_to_all_tabs(self.cfg.font_size) # If the DB path changed, reconnect if self.cfg.path != old_path: self.db.close() if not self._prompt_for_key_until_valid(first_time=False): QMessageBox.warning( - self, "Reopen failed", "Could not unlock database at new path." + self, + strings._("reopen_failed"), + strings._("could_not_unlock_database_at_new_path"), ) return self._load_selected_date() self._refresh_calendar_marks() + # Show or hide the tags and time_log features depending on what the settings are now. + self.tags.hide() if not self.cfg.tags else self.tags.show() + if not self.cfg.time_log: + self.time_log.hide() + self.toolBar.actTimer.setVisible(False) + else: + self.time_log.show() + self.toolBar.actTimer.setVisible(True) + if not self.cfg.reminders: + self.upcoming_reminders.hide() + self.toolBar.actAlarm.setVisible(False) + else: + self.upcoming_reminders.show() + self.toolBar.actAlarm.setVisible(True) + + # ------------ Statistics handler --------------- # + + def _open_statistics(self): + if not getattr(self, "db", None) or self.db.conn is None: + return + + dlg = StatisticsDialog(self.db, self) + + if hasattr(dlg, "_heatmap"): + + def on_date_clicked(d: datetime.date): + qd = QDate(d.year, d.month, d.day) + self._open_date_in_tab(qd) + + dlg._heatmap.date_clicked.connect(on_date_clicked) + dlg.exec() + # ------------ Window positioning --------------- # def _restore_window_position(self): geom = self.settings.value("main/geometry", None) @@ -746,14 +1524,8 @@ class MainWindow(QMainWindow): # ----------------- Export handler ----------------- # @Slot() def _export(self): - warning_title = "Unencrypted export" - warning_message = """ -Exporting the database will be unencrypted! - -Are you sure you want to continue? - -If you want an encrypted backup, choose Backup instead of Export. -""" + warning_title = strings._("unencrypted_export") + warning_message = strings._("unencrypted_export_warning") dlg = QMessageBox() dlg.setWindowTitle(warning_title) dlg.setText(warning_message) @@ -765,7 +1537,6 @@ If you want an encrypted backup, choose Backup instead of Export. return False filters = ( - "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;" @@ -775,28 +1546,25 @@ If you want an encrypted backup, choose Backup instead of Export. start_dir = os.path.join(os.path.expanduser("~"), "Documents") filename, selected_filter = QFileDialog.getSaveFileName( - self, "Export entries", start_dir, filters + self, strings._("export_entries"), start_dir, filters ) if not filename: return # user cancelled default_ext = { - "Text (*.txt)": ".txt", "JSON (*.json)": ".json", "CSV (*.csv)": ".csv", "HTML (*.html)": ".html", "Markdown (*.md)": ".md", "SQL (*.sql)": ".sql", - }.get(selected_filter, ".txt") + }.get(selected_filter, ".md") if not Path(filename).suffix: filename += default_ext try: entries = self.db.get_all_entries() - if selected_filter.startswith("Text"): - self.db.export_txt(entries, filename) - elif selected_filter.startswith("JSON"): + if selected_filter.startswith("JSON"): self.db.export_json(entries, filename) elif selected_filter.startswith("CSV"): self.db.export_csv(entries, filename) @@ -807,11 +1575,15 @@ If you want an encrypted backup, choose Backup instead of Export. elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: - self.db.export_by_extension(filename) + raise ValueError(strings._("unrecognised_extension")) - QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}") + QMessageBox.information( + self, + strings._("export_complete"), + strings._("saved_to") + f" {filename}", + ) except Exception as e: - QMessageBox.critical(self, "Export failed", str(e)) + QMessageBox.critical(self, strings._("export_failed"), str(e)) # ----------------- Backup handler ----------------- # @Slot() @@ -823,7 +1595,7 @@ If you want an encrypted backup, choose Backup instead of Export. os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db" ) filename, selected_filter = QFileDialog.getSaveFileName( - self, "Backup encrypted notebook", start_dir, filters + self, strings._("backup_encrypted_notebook"), start_dir, filters ) if not filename: return # user cancelled @@ -839,10 +1611,12 @@ If you want an encrypted backup, choose Backup instead of Export. if selected_filter.startswith("SQL"): self.db.export_sqlcipher(filename) QMessageBox.information( - self, "Backup complete", f"Saved to:\n{filename}" + self, + strings._("backup_complete"), + strings._("saved_to") + f" {filename}", ) except Exception as e: - QMessageBox.critical(self, "Backup failed", str(e)) + QMessageBox.critical(self, strings._("backup_failed"), str(e)) # ----------------- Help handlers ----------------- # @@ -851,16 +1625,17 @@ If you want an encrypted backup, choose Backup instead of Export. url = QUrl.fromUserInput(url_str) if not QDesktopServices.openUrl(url): QMessageBox.warning( - self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" + self, + strings._("documentation"), + strings._("couldnt_open") + url.toDisplayString(), ) def _open_bugs(self): - url_str = "https://nr.mig5.net/forms/mig5/contact" - url = QUrl.fromUserInput(url_str) - if not QDesktopServices.openUrl(url): - QMessageBox.warning( - self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" - ) + dlg = BugReportDialog(self) + dlg.exec() + + def _open_version(self): + self.version_checker.show_version_dialog() # ----------------- Idle handlers ----------------- # def _apply_idle_minutes(self, minutes: int): @@ -871,22 +1646,27 @@ If you want an encrypted backup, choose Backup instead of Export. self._idle_timer.stop() # If currently locked, unlock when user disables the timer: if getattr(self, "_locked", False): - try: - self._locked = False - if hasattr(self, "_lock_overlay"): - self._lock_overlay.hide() - except Exception: - pass + self._locked = False + if hasattr(self, "_lock_overlay"): + self._lock_overlay.hide() else: self._idle_timer.setInterval(minutes * 60 * 1000) if not getattr(self, "_locked", False): self._idle_timer.start() def eventFilter(self, obj, event): + # Catch right-clicks on calendar BEFORE selectionChanged can fire + if obj == self.calendar and event.type() == QEvent.MouseButtonPress: + # QMouseEvent in PySide6 + if event.button() == Qt.RightButton: + self._showing_context_menu = True + if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() + if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): QTimer.singleShot(0, self._focus_editor_now) + return super().eventFilter(obj, event) def _enter_lock(self): @@ -900,11 +1680,15 @@ If you want an encrypted backup, choose Backup instead of Export. self.menuBar().setEnabled(False) if self.statusBar(): self.statusBar().setEnabled(False) + self.statusBar().hide() tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(False) + tb.hide() self._lock_overlay.show() self._lock_overlay.raise_() + lock_msg = strings._("lock_overlay_locked") + self.setWindowTitle(f"{APP_NAME} ({lock_msg})") @Slot() def _on_unlock_clicked(self): @@ -915,7 +1699,7 @@ If you want an encrypted backup, choose Backup instead of Export. try: ok = self._prompt_for_key_until_valid(first_time=False) except Exception as e: - QMessageBox.critical(self, "Unlock failed", str(e)) + QMessageBox.critical(self, strings._("unlock_failed"), str(e)) return if ok: self._locked = False @@ -924,25 +1708,50 @@ If you want an encrypted backup, choose Backup instead of Export. self.menuBar().setEnabled(True) if self.statusBar(): self.statusBar().setEnabled(True) + self.statusBar().show() tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(True) + tb.show() self._idle_timer.start() QTimer.singleShot(0, self._focus_editor_now) + self.setWindowTitle(APP_NAME) # ----------------- Close handlers ----------------- # def closeEvent(self, event): - try: - # Save window position - self.settings.setValue("main/geometry", self.saveGeometry()) - self.settings.setValue("main/windowState", self.saveState()) - self.settings.setValue("main/maximized", self.isMaximized()) + # Persist geometry if settings exist (window might be half-initialized). + if getattr(self, "settings", None) is not None: + try: + self.settings.setValue("main/geometry", self.saveGeometry()) + self.settings.setValue("main/windowState", self.saveState()) + self.settings.setValue("main/maximized", self.isMaximized()) + except Exception: + pass + + # Stop timers if present to avoid late autosaves firing during teardown. + for _t in ("_autosave_timer", "_idle_timer"): + t = getattr(self, _t, None) + if t: + t.stop() + + # Save content from tabs if the database is still connected + db = getattr(self, "db", None) + conn = getattr(db, "conn", None) + tw = getattr(self, "tab_widget", None) + if db is not None and conn is not None and tw is not None: + try: + for i in range(tw.count()): + editor = tw.widget(i) + if editor is not None: + self._save_editor_content(editor) + except Exception: + # Don't let teardown crash if one tab fails to save. + pass + try: + db.close() + except Exception: + pass - # Ensure we save any last pending edits to the db - self._save_current() - self.db.close() - except Exception: - pass super().closeEvent(event) # ----------------- Below logic helps focus the editor ----------------- # @@ -959,8 +1768,12 @@ If you want an encrypted backup, choose Backup instead of Export. QTimer.singleShot( 0, lambda: ( - self.editor.setFocus(Qt.ActiveWindowFocusReason), - self.editor.ensureCursorVisible(), + ( + self.editor.setFocus(Qt.ActiveWindowFocusReason) + if self.editor + else None + ), + self.editor.ensureCursorVisible() if self.editor else None, ), ) @@ -974,35 +1787,3 @@ If you want an encrypted backup, choose Backup instead of Export. super().changeEvent(ev) if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now) - - def _set_editor_html_preserve_view(self, html: str): - ed = self.editor - - # Save caret/selection and scroll - cur = ed.textCursor() - old_pos, old_anchor = cur.position(), cur.anchor() - v = ed.verticalScrollBar().value() - h = ed.horizontalScrollBar().value() - - # Only touch the doc if it actually changed - ed.blockSignals(True) - if ed.toHtml() != html: - ed.setHtml(html) - ed.blockSignals(False) - - # Restore scroll first - ed.verticalScrollBar().setValue(v) - ed.horizontalScrollBar().setValue(h) - - # Restore caret/selection - cur = ed.textCursor() - cur.setPosition(old_anchor) - mode = ( - QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor - ) - cur.setPosition(old_pos, mode) - ed.setTextCursor(cur) - - # Refresh highlights if the theme changed - if hasattr(self, "findBar"): - self.findBar.refresh() diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py new file mode 100644 index 0000000..9f48858 --- /dev/null +++ b/bouquin/markdown_editor.py @@ -0,0 +1,1311 @@ +from __future__ import annotations + +import base64 +import re +from pathlib import Path + +from PySide6.QtGui import ( + QFont, + QFontDatabase, + QFontMetrics, + QImage, + QTextCharFormat, + QTextCursor, + QTextDocument, + QTextFormat, + QTextBlockFormat, + QTextImageFormat, + QDesktopServices, +) +from PySide6.QtCore import Qt, QRect, QTimer, QUrl +from PySide6.QtWidgets import QTextEdit + +from .theme import ThemeManager +from .markdown_highlighter import MarkdownHighlighter +from . import strings + + +class MarkdownEditor(QTextEdit): + """A QTextEdit that stores/loads markdown and provides live rendering.""" + + _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.theme_manager = theme_manager + + # Setup tab width + tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") + self.setTabStopDistance(tab_w) + + # We accept plain text, not rich text (markdown is plain text) + self.setAcceptRichText(False) + + # Load in our preferred fonts + base_dir = Path(__file__).resolve().parent + + # Load regular text font (primary) + regular_font_path = base_dir / "fonts" / "NotoSans-Regular.ttf" + regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path)) + if regular_font_id == -1: + print("Failed to load NotoSans-Regular.ttf") + + # Load Symbols font (fallback) + symbols_font_path = base_dir / "fonts" / "NotoSansSymbols2-Regular.ttf" + symbols_font_id = QFontDatabase.addApplicationFont(str(symbols_font_path)) + symbols_families = QFontDatabase.applicationFontFamilies(symbols_font_id) + self.symbols_font_family = symbols_families[0] + if symbols_font_id == -1: + print("Failed to load NotoSansSymbols2-Regular.ttf") + + # Use the regular Noto Sans family as the editor font + regular_families = QFontDatabase.applicationFontFamilies(regular_font_id) + if regular_families: + self.text_font_family = regular_families[0] + self.qfont = QFont(self.text_font_family, 11) + self.setFont(self.qfont) + + self._apply_line_spacing() # 1.25× initial spacing + + # Checkbox characters (Unicode for display, markdown for storage) + self._CHECK_UNCHECKED_DISPLAY = "☐" + self._CHECK_CHECKED_DISPLAY = "☑" + self._CHECK_UNCHECKED_STORAGE = "[ ]" + self._CHECK_CHECKED_STORAGE = "[x]" + + # Bullet character (Unicode for display, "- " for markdown) + self._BULLET_DISPLAY = "•" + self._BULLET_STORAGE = "-" + + # Install syntax highlighter + self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self) + + # Initialize code block metadata + from .code_highlighter import CodeBlockMetadata + + self._code_metadata = CodeBlockMetadata() + + # Track current list type for smart enter handling + self._last_enter_was_empty = False + + # Track if we're currently updating text programmatically + self._updating = False + + # Connect to text changes for smart formatting + self.textChanged.connect(self._on_text_changed) + self.textChanged.connect(self._update_code_block_row_backgrounds) + self.theme_manager.themeChanged.connect( + lambda *_: self._update_code_block_row_backgrounds() + ) + + # Enable mouse tracking for checkbox clicking + self.viewport().setMouseTracking(True) + # Also mark links as mouse-accessible + flags = self.textInteractionFlags() + self.setTextInteractionFlags( + flags | Qt.TextInteractionFlag.LinksAccessibleByMouse + ) + + def setDocument(self, doc): + super().setDocument(doc) + # Recreate the highlighter for the new document + # (the old one gets deleted with the old document) + if hasattr(self, "highlighter") and hasattr(self, "theme_manager"): + self.highlighter = MarkdownHighlighter( + self.document(), self.theme_manager, self + ) + self._apply_line_spacing() + self._apply_code_block_spacing() + QTimer.singleShot(0, self._update_code_block_row_backgrounds) + + def setFont(self, font: QFont) -> None: # type: ignore[override] + """ + Ensure that whenever the base editor font changes, our highlighter + re-computes checkbox / bullet formats. + """ + # Keep qfont in sync + self.qfont = QFont(font) + super().setFont(self.qfont) + + # If the highlighter is already attached, let it rebuild its formats + highlighter = getattr(self, "highlighter", None) + if highlighter is not None: + refresh = getattr(highlighter, "refresh_for_font_change", None) + if callable(refresh): + refresh() + + def showEvent(self, e): + super().showEvent(e) + # First time the widget is shown, Qt may rebuild layout once more. + QTimer.singleShot(0, self._update_code_block_row_backgrounds) + + def _on_text_changed(self): + """Handle live formatting updates - convert checkbox markdown to Unicode.""" + if self._updating: + return + + self._updating = True + try: + c = self.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + # Transform markdown checkboxes and 'TODO' to unicode checkboxes + def transform_line(s: str) -> str: + s = s.replace( + f"- {self._CHECK_CHECKED_STORAGE} ", + f"{self._CHECK_CHECKED_DISPLAY} ", + ) + s = s.replace( + f"- {self._CHECK_UNCHECKED_STORAGE} ", + f"{self._CHECK_UNCHECKED_DISPLAY} ", + ) + s = re.sub( + r"^([ \t]*)TODO\b[:\-]?\s+", + lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ", + s, + ) + return s + + new_line = transform_line(line) + if new_line != line: + # Replace just the current block + bc = QTextCursor(block) + bc.beginEditBlock() + bc.select(QTextCursor.BlockUnderCursor) + bc.insertText(new_line) + bc.endEditBlock() + + # Restore cursor near its original visual position in the edited line + new_pos = min( + block.position() + len(new_line), block.position() + pos_in_block + ) + c.setPosition(new_pos) + self.setTextCursor(c) + finally: + self._updating = False + + def _is_inside_code_block(self, block): + """Return True if 'block' is inside a fenced code block (based on fences above).""" + inside = False + b = block.previous() + while b.isValid(): + if b.text().strip().startswith("```"): + inside = not inside + b = b.previous() + return inside + + def _update_code_block_row_backgrounds(self): + """Paint a full-width background for each line that is in a fenced code block.""" + doc = self.document() + if doc is None: + return + + sels = [] + bg_brush = self.highlighter.code_block_format.background() + + inside = False + block = doc.begin() + while block.isValid(): + text = block.text() + stripped = text.strip() + is_fence = stripped.startswith("```") + + paint_this_line = is_fence or inside + if paint_this_line: + sel = QTextEdit.ExtraSelection() + fmt = QTextCharFormat() + fmt.setBackground(bg_brush) + fmt.setProperty(QTextFormat.FullWidthSelection, True) + fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") + sel.format = fmt + + cur = QTextCursor(doc) + cur.setPosition(block.position()) + sel.cursor = cur + sels.append(sel) + + if is_fence: + inside = not inside + + block = block.next() + + others = [ + s + for s in self.extraSelections() + if s.format.property(QTextFormat.UserProperty) != "codeblock_bg" + ] + self.setExtraSelections(others + sels) + + def _apply_line_spacing(self, height: float = 125.0): + """Apply proportional line spacing to the whole document.""" + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + cursor.beginEditBlock() + cursor.select(QTextCursor.Document) + + fmt = QTextBlockFormat() + fmt.setLineHeight( + height, # 125.0 = 1.25× + QTextBlockFormat.LineHeightTypes.ProportionalHeight.value, + ) + cursor.mergeBlockFormat(fmt) + cursor.endEditBlock() + + def _apply_code_block_spacing(self): + """ + Make all fenced code-block lines (including ``` fences) single-spaced. + Call this AFTER _apply_line_spacing(). + """ + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + cursor.beginEditBlock() + + inside = False + block = doc.begin() + while block.isValid(): + text = block.text() + stripped = text.strip() + is_fence = stripped.startswith("```") + is_code_line = is_fence or inside + + if is_code_line: + fmt = block.blockFormat() + fmt.setLineHeight( + 0.0, + QTextBlockFormat.LineHeightTypes.SingleHeight.value, + ) + cursor.setPosition(block.position()) + cursor.setBlockFormat(fmt) + + if is_fence: + inside = not inside + + block = block.next() + + cursor.endEditBlock() + + def to_markdown(self) -> str: + """Export current content as markdown.""" + # First, extract any embedded images and convert to markdown + text = self._extract_images_to_markdown() + + # Convert Unicode checkboxes back to markdown syntax + text = text.replace( + f"{self._CHECK_CHECKED_DISPLAY} ", f"- {self._CHECK_CHECKED_STORAGE} " + ) + text = text.replace( + f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} " + ) + + # Convert Unicode bullets back to "- " at the start of a line + text = re.sub( + rf"(?m)^(\s*){re.escape(self._BULLET_DISPLAY)}\s+", + rf"\1{self._BULLET_STORAGE} ", + text, + ) + + # Append code block metadata if present + if hasattr(self, "_code_metadata"): + metadata_str = self._code_metadata.serialize() + if metadata_str: + text = text.rstrip() + "\n\n" + metadata_str + + return text + + def _extract_images_to_markdown(self) -> str: + """Extract embedded images and convert them back to markdown format.""" + doc = self.document() + cursor = QTextCursor(doc) + + # Build the output text with images as markdown + result = [] + cursor.movePosition(QTextCursor.MoveOperation.Start) + + block = doc.begin() + while block.isValid(): + it = block.begin() + block_text = "" + + while not it.atEnd(): + fragment = it.fragment() + if fragment.isValid(): + if fragment.charFormat().isImageFormat(): + # This is an image - convert to markdown + img_format = fragment.charFormat().toImageFormat() + img_name = img_format.name() + # The name contains the data URI + if img_name.startswith("data:image/"): + block_text += f"![image]({img_name})" + else: + # Regular text + block_text += fragment.text() + it += 1 + + result.append(block_text) + block = block.next() + + return "\n".join(result) + + def from_markdown(self, markdown_text: str): + """Load markdown text into the editor.""" + # Extract and load code block metadata if present + from .code_highlighter import CodeBlockMetadata + + if not hasattr(self, "_code_metadata"): + self._code_metadata = CodeBlockMetadata() + + self._code_metadata.deserialize(markdown_text) + # Remove metadata comment from displayed text + markdown_text = re.sub(r"\s*\s*$", "", markdown_text) + + # Convert markdown checkboxes to Unicode for display + display_text = markdown_text.replace( + f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} " + ) + display_text = display_text.replace( + f"- {self._CHECK_UNCHECKED_STORAGE} ", f"{self._CHECK_UNCHECKED_DISPLAY} " + ) + # Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox + display_text = re.sub( + r"(?m)^([ \t]*)TODO\s", + lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ", + display_text, + ) + + # Convert simple markdown bullets ("- ", "* ", "+ ") to Unicode bullets, + # but skip checkbox lines (- [ ] / - [x]) + display_text = re.sub( + r"(?m)^([ \t]*)[-*+]\s+(?!\[[ xX]\])", + rf"\1{self._BULLET_DISPLAY} ", + display_text, + ) + + self._updating = True + try: + self.setPlainText(display_text) + if hasattr(self, "highlighter") and self.highlighter: + self.highlighter.rehighlight() + finally: + self._updating = False + + self._apply_line_spacing() + self._apply_code_block_spacing() + + # Render any embedded images + self._render_images() + + self._update_code_block_row_backgrounds() + QTimer.singleShot(0, self._update_code_block_row_backgrounds) + + def _render_images(self): + """Find and render base64 images in the document.""" + text = self.toPlainText() + + # Pattern for markdown images with base64 data + img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)" + + matches = list(re.finditer(img_pattern, text)) + + if not matches: + return + + # Process matches in reverse to preserve positions + for match in reversed(matches): + mime_type = match.group(2) + b64_data = match.group(3) + + # Decode base64 to image + img_bytes = base64.b64decode(b64_data) + image = QImage.fromData(img_bytes) + + if image.isNull(): + continue + + # Use original image size - no scaling + original_width = image.width() + original_height = image.height() + + # Create image format with original base64 + img_format = QTextImageFormat() + img_format.setName(f"data:image/{mime_type};base64,{b64_data}") + img_format.setWidth(original_width) + img_format.setHeight(original_height) + + # Add image to document resources + self.document().addResource( + QTextDocument.ResourceType.ImageResource, img_format.name(), image + ) + + # Replace markdown with rendered image + cursor = QTextCursor(self.document()) + cursor.setPosition(match.start()) + cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor) + cursor.insertImage(img_format) + + def _get_current_line(self) -> str: + """Get the text of the current line.""" + cursor = self.textCursor() + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + return cursor.selectedText() + + def _list_prefix_length_for_block(self, block) -> int: + """Return the length (in chars) of the visual list prefix for the given + block (including leading indentation), or 0 if it's not a list item. + """ + line = block.text() + stripped = line.lstrip() + leading_spaces = len(line) - len(stripped) + + # Checkbox (Unicode display) + if stripped.startswith( + f"{self._CHECK_UNCHECKED_DISPLAY} " + ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "): + return leading_spaces + 2 # icon + space + + # Unicode bullet + if stripped.startswith(f"{self._BULLET_DISPLAY} "): + return leading_spaces + 2 # bullet + space + + # Markdown bullet list (-, *, +) + if re.match(r"^[-*+]\s", stripped): + return leading_spaces + 2 # marker + space + + # Numbered list: e.g. "1. " + m = re.match(r"^(\d+\.\s)", stripped) + if m: + return leading_spaces + leading_spaces + (len(m.group(1)) - leading_spaces) + + return 0 + + def _detect_list_type(self, line: str) -> tuple[str | None, str]: + """ + Detect if line is a list item. Returns (list_type, prefix). + list_type: 'bullet', 'number', 'checkbox', or None + prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ') + """ + line = line.lstrip() + + # Checkbox list (Unicode display format) + if line.startswith(f"{self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith( + f"{self._CHECK_CHECKED_DISPLAY} " + ): + return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") + + # Bullet list – Unicode bullet + if line.startswith(f"{self._BULLET_DISPLAY} "): + return ("bullet", f"{self._BULLET_DISPLAY} ") + + # Bullet list - markdown bullet + if re.match(r"^[-*+]\s", line): + match = re.match(r"^([-*+]\s)", line) + return ("bullet", match.group(1)) + + # Numbered list + if re.match(r"^\d+\.\s", line): + # Extract the number and increment + match = re.match(r"^(\d+)\.\s", line) + num = int(match.group(1)) + return ("number", f"{num + 1}. ") + + return (None, "") + + def _url_at_pos(self, pos) -> str | None: + """ + Return the URL under the given widget position, or None if there isn't one. + """ + cursor = self.cursorForPosition(pos) + block = cursor.block() + text = block.text() + if not text: + return None + + # Position of the cursor inside this block + pos_in_block = cursor.position() - block.position() + + # Same pattern as in MarkdownHighlighter + url_pattern = re.compile(r"(https?://[^\s<>()]+)") + for m in url_pattern.finditer(text): + start, end = m.span(1) + if start <= pos_in_block < end: + return m.group(1) + + return None + + def keyPressEvent(self, event): + """Handle special key events for markdown editing.""" + + # --- Auto-close code fences when typing the 3rd backtick at line start --- + if event.text() == "`": + c = self.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + # text before caret on this line + before = line[:pos_in_block] + + # If we've typed exactly two backticks at line start (or after whitespace), + # treat this backtick as the "third" and expand to a full fenced block. + if before.endswith("``") and before.strip() == "``": + start = ( + block.position() + pos_in_block - 2 + ) # start of the two backticks + + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start) + edit.setPosition(start + 2, QTextCursor.KeepAnchor) + edit.insertText("```\n\n```\n") + edit.endEditBlock() + + # place caret on the blank line between the fences + new_pos = start + 4 # after "```\n" + c.setPosition(new_pos) + self.setTextCursor(c) + return + + # Step out of a code block with Down at EOF + if event.key() == Qt.Key.Key_Down: + c = self.textCursor() + b = c.block() + pos_in_block = c.position() - b.position() + line = b.text() + + def next_is_closing(bb): + nb = bb.next() + return nb.isValid() and nb.text().strip().startswith("```") + + # Case A: caret is on the line BEFORE the closing fence, at EOL → jump after the fence + if ( + self._is_inside_code_block(b) + and pos_in_block >= len(line) + and next_is_closing(b) + ): + fence_block = b.next() + after_fence = fence_block.next() + if not after_fence.isValid(): + # make a line after the fence + edit = QTextCursor(self.document()) + endpos = fence_block.position() + len(fence_block.text()) + edit.setPosition(endpos) + edit.insertText("\n") + after_fence = fence_block.next() + c.setPosition(after_fence.position()) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + return + + # Case B: caret is ON the closing fence, and it's EOF → create a line and move to it + if ( + b.text().strip().startswith("```") + and self._is_inside_code_block(b) + and not b.next().isValid() + ): + edit = QTextCursor(self.document()) + edit.setPosition(b.position() + len(b.text())) + edit.insertText("\n") + c.setPosition(b.position() + len(b.text()) + 1) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + return + + # Handle Backspace on empty list items so the marker itself can be deleted + if event.key() == Qt.Key.Key_Backspace: + cursor = self.textCursor() + # Let Backspace behave normally when deleting a selection. + if not cursor.hasSelection(): + block = cursor.block() + prefix_len = self._list_prefix_length_for_block(block) + + if prefix_len > 0: + block_start = block.position() + line = block.text() + pos_in_block = cursor.position() - block_start + after_text = line[prefix_len:] + + # If there is no real content after the marker, treat Backspace + # as "remove the list marker". + if after_text.strip() == "" and pos_in_block >= prefix_len: + cursor.beginEditBlock() + cursor.setPosition(block_start) + cursor.setPosition( + block_start + prefix_len, QTextCursor.KeepAnchor + ) + cursor.removeSelectedText() + cursor.endEditBlock() + self.setTextCursor(cursor) + return + + # Handle Home and Left arrow keys to keep the caret to the *right* + # of list prefixes (checkboxes / bullets / numbers). + if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left): + # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of + # document / word-left) – we don't interfere with those. + if event.modifiers() & Qt.ControlModifier: + pass + else: + cursor = self.textCursor() + block = cursor.block() + prefix_len = self._list_prefix_length_for_block(block) + + if prefix_len > 0: + block_start = block.position() + pos_in_block = cursor.position() - block_start + target = block_start + prefix_len + + if event.key() == Qt.Key.Key_Home: + # Home should jump to just after the prefix; with Shift + # it should *select* back to that position. + if event.modifiers() & Qt.ShiftModifier: + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) + self.setTextCursor(cursor) + return + + # Left arrow: don't allow the caret to move into the prefix + # region; snap it to just after the marker instead. + if event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len: + if event.modifiers() & Qt.ShiftModifier: + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) + self.setTextCursor(cursor) + return + + # After moving vertically, make sure we don't land *inside* a list + # prefix. We let QTextEdit perform the move first and then adjust. + if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not ( + event.modifiers() & Qt.ControlModifier + ): + super().keyPressEvent(event) + + cursor = self.textCursor() + block = cursor.block() + + # Don't interfere with code blocks (they can contain literal + # markdown-looking text). + if self._is_inside_code_block(block): + return + + prefix_len = self._list_prefix_length_for_block(block) + if prefix_len > 0: + block_start = block.position() + pos_in_block = cursor.position() - block_start + if pos_in_block < prefix_len: + target = block_start + prefix_len + if event.modifiers() & Qt.ShiftModifier: + # Preserve the current anchor while snapping the visual + # caret to just after the marker. + anchor = cursor.anchor() + cursor.setPosition(anchor) + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) + self.setTextCursor(cursor) + + return + + # Handle Enter key for smart list continuation AND code blocks + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + cursor = self.textCursor() + current_line = self._get_current_line() + + # Check if we're in a code block + current_block = cursor.block() + line_text = current_block.text() + pos_in_block = cursor.position() - current_block.position() + + moved = False + i = 0 + patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code + # Consume stacked markers like **` if present + while True: + matched = False + for pat in patterns: + L = len(pat) + if line_text[pos_in_block + i : pos_in_block + i + L] == pat: + i += L + matched = True + moved = True + break + if not matched: + break + if moved: + cursor.movePosition( + QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i + ) + self.setTextCursor(cursor) + + block_state = current_block.userState() + + stripped = current_line.strip() + is_fence_line = stripped.startswith("```") + + if is_fence_line: + # Work out if this fence is closing (inside block before it) + inside_before = self._is_inside_code_block(current_block.previous()) + + # Insert the newline as usual + super().keyPressEvent(event) + + if inside_before: + # We were on the *closing* fence; the new line is outside the block. + # Give that new block normal 1.25× spacing. + new_block = self.textCursor().block() + fmt = new_block.blockFormat() + fmt.setLineHeight( + 125.0, + QTextBlockFormat.LineHeightTypes.ProportionalHeight.value, + ) + cur2 = self.textCursor() + cur2.setBlockFormat(fmt) + self.setTextCursor(cur2) + + return + + # Inside a code block (but not on a fence): newline stays code-style + if block_state == 1: + super().keyPressEvent(event) + return + + # Check for list continuation + list_type, prefix = self._detect_list_type(current_line) + + if list_type: + # Check if the line is empty (just the prefix) + content = current_line.lstrip() + is_empty = ( + content == prefix.strip() or not content.replace(prefix, "").strip() + ) + + if is_empty and self._last_enter_was_empty: + # Second enter on empty list item - remove the list formatting + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.insertText("\n") + self._last_enter_was_empty = False + return + elif is_empty: + # First enter on empty list item - just insert newline without prefix + super().keyPressEvent(event) + self._last_enter_was_empty = True + return + else: + # Not empty - continue the list + self._last_enter_was_empty = False + + # Insert newline and continue the list + super().keyPressEvent(event) + cursor = self.textCursor() + cursor.insertText(prefix) + return + else: + self._last_enter_was_empty = False + else: + # Any other key resets the empty enter flag + self._last_enter_was_empty = False + + # Default handling + super().keyPressEvent(event) + + def mouseMoveEvent(self, event): + # Change cursor when hovering a link + url = self._url_at_pos(event.pos()) + if url: + self.viewport().setCursor(Qt.PointingHandCursor) + else: + self.viewport().setCursor(Qt.IBeamCursor) + + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + # Let QTextEdit handle caret/selection first + super().mouseReleaseEvent(event) + + if event.button() != Qt.LeftButton: + return + + # If the user dragged to select text, don't treat it as a click + if self.textCursor().hasSelection(): + return + + url_str = self._url_at_pos(event.pos()) + if not url_str: + return + + url = QUrl(url_str) + if not url.scheme(): + url.setScheme("https") + + QDesktopServices.openUrl(url) + + def mousePressEvent(self, event): + """Toggle a checkbox only when the click lands on its icon.""" + if event.button() == Qt.LeftButton: + pt = event.pos() + + # Cursor and block under the mouse + cur = self.cursorForPosition(pt) + block = cur.block() + text = block.text() + + # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space) + unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} " + checked = f"{self._CHECK_CHECKED_DISPLAY} " + + # Helper: rect for a single character at a given doc position + def char_rect_at(doc_pos, ch): + c = QTextCursor(self.document()) + c.setPosition(doc_pos) + # caret rect at char start (viewport coords) + start_rect = self.cursorRect(c) + + # Use the actual font at this position for an accurate width + fmt_font = ( + c.charFormat().font() if c.charFormat().isValid() else self.font() + ) + fm = QFontMetrics(fmt_font) + w = max(1, fm.horizontalAdvance(ch)) + return QRect(start_rect.x(), start_rect.y(), w, start_rect.height()) + + # Scan the line for any checkbox icons; toggle the one we clicked + i = 0 + while i < len(text): + icon = None + if text.startswith(unchecked, i): + icon = self._CHECK_UNCHECKED_DISPLAY + elif text.startswith(checked, i): + icon = self._CHECK_CHECKED_DISPLAY + + if icon: + # absolute document position of the icon + doc_pos = block.position() + i + r = char_rect_at(doc_pos, icon) + + if r.contains(pt): + # Build the replacement: swap ☐ <-> ☑ (keep trailing space) + new_icon = ( + self._CHECK_CHECKED_DISPLAY + if icon == self._CHECK_UNCHECKED_DISPLAY + else self._CHECK_UNCHECKED_DISPLAY + ) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(doc_pos) + # icon + space + edit.movePosition( + QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1 + ) + edit.insertText(f"{new_icon} ") + edit.endEditBlock() + return # handled + + # advance past this token + i += len(icon) + 1 + else: + i += 1 + + # Default handling for anything else + super().mousePressEvent(event) + + # ------------------------ Toolbar action handlers ------------------------ + + def apply_weight(self): + """Toggle bold formatting.""" + cursor = self.textCursor() + if cursor.hasSelection(): + selected = cursor.selectedText() + # Check if already bold + if selected.startswith("**") and selected.endswith("**"): + # Remove bold + new_text = selected[2:-2] + else: + # Add bold + new_text = f"**{selected}**" + cursor.insertText(new_text) + else: + # No selection - just insert markers + cursor.insertText("****") + cursor.movePosition( + QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 + ) + self.setTextCursor(cursor) + + # Return focus to editor + self.setFocus() + + def apply_italic(self): + """Toggle italic formatting.""" + cursor = self.textCursor() + if cursor.hasSelection(): + selected = cursor.selectedText() + if ( + selected.startswith("*") + and selected.endswith("*") + and not selected.startswith("**") + ): + new_text = selected[1:-1] + else: + new_text = f"*{selected}*" + cursor.insertText(new_text) + else: + cursor.insertText("**") + cursor.movePosition( + QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1 + ) + self.setTextCursor(cursor) + + # Return focus to editor + self.setFocus() + + def apply_strikethrough(self): + """Toggle strikethrough formatting.""" + cursor = self.textCursor() + if cursor.hasSelection(): + selected = cursor.selectedText() + if selected.startswith("~~") and selected.endswith("~~"): + new_text = selected[2:-2] + else: + new_text = f"~~{selected}~~" + cursor.insertText(new_text) + else: + cursor.insertText("~~~~") + cursor.movePosition( + QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 + ) + self.setTextCursor(cursor) + + # Return focus to editor + self.setFocus() + + def apply_code(self): + """Insert a fenced code block, or navigate fences without creating inline backticks.""" + c = self.textCursor() + doc = self.document() + + if c.hasSelection(): + # Wrap selection and ensure exactly one newline after the closing fence + selected = c.selectedText().replace("\u2029", "\n") + c.insertText(f"```\n{selected.rstrip()}\n```\n") + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + # tighten spacing for the new code block + self._apply_code_block_spacing() + + self.setFocus() + return + + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + stripped = line.strip() + + # If we're on a fence line, be helpful but never insert inline fences + if stripped.startswith("```"): + # Is this fence opening or closing? (look at blocks above) + inside_before = self._is_inside_code_block(block.previous()) + if inside_before: + # This fence closes the block → ensure a line after, then move there + endpos = block.position() + len(line) + edit = QTextCursor(doc) + edit.setPosition(endpos) + if not block.next().isValid(): + edit.insertText("\n") + c.setPosition(endpos + 1) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + self.setFocus() + return + else: + # Opening fence → move caret to the next line (inside the block) + nb = block.next() + if not nb.isValid(): + e = QTextCursor(doc) + e.setPosition(block.position() + len(line)) + e.insertText("\n") + nb = block.next() + c.setPosition(nb.position()) + self.setTextCursor(c) + self.setFocus() + return + + # If we're inside a block (but not on a fence), don't mutate text + if self._is_inside_code_block(block): + self.setFocus() + return + + # Outside any block → create a clean template on its own lines (never inline) + start_pos = c.position() + before = line[:pos_in_block] + + edit = QTextCursor(doc) + edit.beginEditBlock() + + # If there is text before the caret on the line, start the block on a new line + lead_break = "\n" if before else "" + # Insert the block; trailing newline guarantees you can Down-arrow out later + insert = f"{lead_break}```\n\n```\n" + edit.setPosition(start_pos) + edit.insertText(insert) + edit.endEditBlock() + + # Put caret on the blank line inside the block + c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" + self.setTextCursor(c) + + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + + # tighten spacing for the new code block + self._apply_code_block_spacing() + + self.setFocus() + + def apply_heading(self, size: int): + """Apply heading formatting to current line.""" + cursor = self.textCursor() + + # Determine heading level from size + if size >= 24: + level = 1 + elif size >= 18: + level = 2 + elif size >= 14: + level = 3 + else: + level = 0 # Normal text + + # Get current line + cursor.movePosition( + QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor + ) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor + ) + line = cursor.selectedText() + + # Remove existing heading markers + line = re.sub(r"^#{1,6}\s+", "", line) + + # Add new heading markers if not normal + if level > 0: + new_line = "#" * level + " " + line + else: + new_line = line + + cursor.insertText(new_line) + + # Return focus to editor + self.setFocus() + + def toggle_bullets(self): + """Toggle bullet list on current line.""" + cursor = self.textCursor() + cursor.movePosition( + QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor + ) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor + ) + line = cursor.selectedText() + stripped = line.lstrip() + + # Consider existing markdown markers OR our Unicode bullet as "a bullet" + if ( + stripped.startswith(f"{self._BULLET_DISPLAY} ") + or stripped.startswith("- ") + or stripped.startswith("* ") + ): + # Remove any of those bullet markers + pattern = rf"^\s*([{re.escape(self._BULLET_DISPLAY)}\-*])\s+" + new_line = re.sub(pattern, "", line) + else: + new_line = f"{self._BULLET_DISPLAY} " + stripped + + cursor.insertText(new_line) + + # Return focus to editor + self.setFocus() + + def toggle_numbers(self): + """Toggle numbered list on current line.""" + cursor = self.textCursor() + cursor.movePosition( + QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor + ) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor + ) + line = cursor.selectedText() + + # Check if already numbered + if re.match(r"^\s*\d+\.\s", line): + # Remove number + new_line = re.sub(r"^\s*\d+\.\s+", "", line) + else: + # Add number + new_line = "1. " + line.lstrip() + + cursor.insertText(new_line) + + # Return focus to editor + self.setFocus() + + def toggle_checkboxes(self): + """Toggle checkbox on current line.""" + cursor = self.textCursor() + cursor.movePosition( + QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor + ) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor + ) + line = cursor.selectedText() + + # Check if already has checkbox (Unicode display format) + if ( + f"{self._CHECK_UNCHECKED_DISPLAY} " in line + or f"{self._CHECK_CHECKED_DISPLAY} " in line + ): + # Remove checkbox - use raw string to avoid escape sequence warning + new_line = re.sub( + rf"^\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+", + "", + line, + ) + else: + # Add checkbox (Unicode display format) + new_line = f"{self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip() + + cursor.insertText(new_line) + + # Return focus to editor + self.setFocus() + + def insert_image_from_path(self, path: Path): + """Insert an image as rendered image (but save as base64 markdown).""" + if not path.exists(): + return + + # Read the original image file bytes for base64 encoding + with open(path, "rb") as f: + img_data = f.read() + + # Encode ORIGINAL file bytes to base64 + b64_data = base64.b64encode(img_data).decode("ascii") + + # Determine mime type + ext = path.suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".webp": "image/webp", + } + mime_type = mime_map.get(ext, "image/png") + + # Load the image + image = QImage(str(path)) + if image.isNull(): + return + + # Create image format with original base64 + img_format = QTextImageFormat() + img_format.setName(f"data:image/{mime_type};base64,{b64_data}") + img_format.setWidth(image.width()) + img_format.setHeight(image.height()) + + # Add original image to document resources + self.document().addResource( + QTextDocument.ResourceType.ImageResource, img_format.name(), image + ) + + # Insert the image at original size + cursor = self.textCursor() + cursor.insertImage(img_format) + cursor.insertText("\n") # Add newline after image + + # ========== Context Menu Support ========== + + def contextMenuEvent(self, event): + """Override context menu to add custom actions.""" + from PySide6.QtGui import QAction + from PySide6.QtWidgets import QMenu + + menu = QMenu(self) + cursor = self.cursorForPosition(event.pos()) + + # Check if we're in a code block + block = cursor.block() + if self._is_inside_code_block(block): + # Add language selection submenu + lang_menu = menu.addMenu(strings._("set_code_language")) + + languages = [ + "python", + "bash", + "php", + "javascript", + "html", + "css", + "sql", + "java", + "go", + ] + for lang in languages: + action = QAction(lang.capitalize(), self) + action.triggered.connect( + lambda checked, l=lang: self._set_code_block_language(block, l) + ) + lang_menu.addAction(action) + + menu.addSeparator() + + # Add standard context menu actions + if self.textCursor().hasSelection(): + menu.addAction(strings._("cut"), self.cut) + menu.addAction(strings._("copy"), self.copy) + + menu.addAction(strings._("paste"), self.paste) + + menu.exec(event.globalPos()) + + def _set_code_block_language(self, block, language: str): + """Set the language for a code block and store metadata.""" + if not hasattr(self, "_code_metadata"): + from .code_highlighter import CodeBlockMetadata + + self._code_metadata = CodeBlockMetadata() + + # Find the opening fence block for this code block + fence_block = block + while fence_block.isValid() and not fence_block.text().strip().startswith( + "```" + ): + fence_block = fence_block.previous() + + if fence_block.isValid(): + self._code_metadata.set_language(fence_block.blockNumber(), language) + # Trigger rehighlight + self.highlighter.rehighlight() + + def get_current_line_text(self) -> str: + """Get the text of the current line.""" + cursor = self.textCursor() + block = cursor.block() + return block.text() diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py new file mode 100644 index 0000000..f9826ff --- /dev/null +++ b/bouquin/markdown_highlighter.py @@ -0,0 +1,344 @@ +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"(? 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) diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py new file mode 100644 index 0000000..fd29742 --- /dev/null +++ b/bouquin/pomodoro_timer.py @@ -0,0 +1,149 @@ +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() diff --git a/bouquin/reminders.py b/bouquin/reminders.py new file mode 100644 index 0000000..5306206 --- /dev/null +++ b/bouquin/reminders.py @@ -0,0 +1,639 @@ +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() diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 27feeaf..6b4e05d 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime +from PySide6.QtGui import QFontMetrics from PySide6.QtWidgets import ( QDialog, QVBoxLayout, @@ -10,25 +11,36 @@ 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(title) + + self.setWindowTitle(strings._("enter_a_name_for_this_version")) + v = QVBoxLayout(self) - v.addWidget(QLabel(message)) + v.addWidget(QLabel(strings._("enter_a_name_for_this_version"))) + self.note = QLineEdit() now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - self.note.setText(f"New version I saved at {now}") + text = strings._("new_version_i_saved_at") + f" {now}" + self.note.setText(text) 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) diff --git a/bouquin/search.py b/bouquin/search.py index bbe5a53..95a94de 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -4,7 +4,6 @@ import re from typing import Iterable, Tuple from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument from PySide6.QtWidgets import ( QFrame, QLabel, @@ -17,6 +16,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from . import strings + Row = Tuple[str, str] @@ -31,7 +32,7 @@ class Search(QWidget): self._db = db self.search = QLineEdit() - self.search.setPlaceholderText("Search for notes here") + self.search.setPlaceholderText(strings._("search_for_notes_here")) self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.search.textChanged.connect(self._search) @@ -67,10 +68,7 @@ class Search(QWidget): self.resultDatesChanged.emit([]) # clear highlights return - try: - rows: Iterable[Row] = self._db.search_entries(q) - except Exception: - rows = [] + rows: Iterable[Row] = self._db.search_entries(q) self._populate_results(q, rows) @@ -87,10 +85,7 @@ class Search(QWidget): for date_str, content in rows: # Build an HTML fragment around the match and whether to show ellipses - frag_html, left_ell, right_ell = self._make_html_snippet( - content, query, radius=30, maxlen=90 - ) - + frag_html = 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) @@ -112,11 +107,6 @@ 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) @@ -128,11 +118,6 @@ 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() @@ -149,10 +134,10 @@ class Search(QWidget): self.results.setItemWidget(item, container) # --- Snippet/highlight helpers ----------------------------------------- - def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180): - doc = QTextDocument() - doc.setHtml(html_src) - plain = doc.toPlainText() + 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) if not plain: return "", False, False @@ -179,30 +164,45 @@ class Search(QWidget): start = max(0, min(idx - radius, max(0, L - maxlen))) end = min(L, max(idx + mlen + radius, start + maxlen)) - # Bold all token matches that fall inside [start, end) + # Extract snippet and highlight matches + snippet = plain[start:end] + + # Escape HTML and bold matches + import html as _html + + snippet_html = _html.escape(snippet) if tokens: - lower = plain.lower() - fmt = QTextCharFormat() - fmt.setFontWeight(QFont.Weight.Bold) for t in tokens: - t_low = t.lower() - pos = start - while True: - k = lower.find(t_low, pos) - if k == -1 or k >= end: - break - c = QTextCursor(doc) - c.setPosition(k) - c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor) - c.mergeCharFormat(fmt) - pos = k + len(t) + # Case-insensitive replacement + pattern = re.compile(re.escape(t), re.IGNORECASE) + snippet_html = pattern.sub( + lambda m: f"{m.group(0)}", snippet_html + ) - # Select the window and export as HTML fragment - c = QTextCursor(doc) - c.setPosition(start) - c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) - fragment_html = ( - c.selection().toHtml() - ) # preserves original styles + our bolding + return snippet_html - return fragment_html, start > 0, end < L + def _strip_markdown(self, markdown: str) -> str: + """Strip markdown formatting for plain text display.""" + # Remove images + text = re.sub(r"!\[.*?\]\(.*?\)", "[Image]", markdown) + # Remove links but keep text + text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text) + # Remove inline code backticks + text = re.sub(r"`([^`]+)`", r"\1", text) + # Remove bold/italic markers + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"__([^_]+)__", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + text = re.sub(r"_([^_]+)_", r"\1", text) + # Remove strikethrough + text = re.sub(r"~~([^~]+)~~", r"\1", text) + # Remove heading markers + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) + # Remove list markers + text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE) + text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE) + # Remove checkbox markers + text = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", text, flags=re.MULTILINE) + # Remove code block fences + text = re.sub(r"```[^\n]*\n", "", text) + return text.strip() diff --git a/bouquin/settings.py b/bouquin/settings.py index 2201b09..011d39a 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -9,31 +9,66 @@ 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() - path = Path(s.value("db/path", str(default_db_path()))) + + # --- 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 ------------------------------------------------ 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 + 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, ) def save_db_config(cfg: DBConfig) -> None: s = get_settings() - s.setValue("db/path", str(cfg.path)) + s.setValue("db/default_db", 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)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 5b11381..90f301d 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -4,22 +4,21 @@ 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 @@ -30,32 +29,64 @@ 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("Settings") + self.setWindowTitle(strings._("settings")) self._cfg = DBConfig(path=cfg.path, key="") self._db = db self.key = "" - form = QFormLayout() - form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - self.setMinimumWidth(560) + self.current_settings = load_db_config() + + self.setMinimumWidth(480) self.setSizeGripEnabled(True) - current_settings = load_db_config() + # --- Tabs ---------------------------------------------------------- + tabs = QTabWidget() + tabs.setTabPosition(QTabWidget.North) + tabs.setDocumentMode(True) + tabs.setMovable(False) - # Add theme selection - theme_group = QGroupBox("Theme") + 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")) theme_layout = QVBoxLayout(theme_group) - self.theme_system = QRadioButton("System") - self.theme_light = QRadioButton("Light") - self.theme_dark = QRadioButton("Dark") + self.theme_system = QRadioButton(strings._("system")) + self.theme_light = QRadioButton(strings._("light")) + self.theme_dark = QRadioButton(strings._("dark")) - # Load current theme from settings - current_theme = current_settings.theme + current_theme = self.current_settings.theme if current_theme == Theme.DARK.value: self.theme_dark.setChecked(True) elif current_theme == Theme.LIGHT.value: @@ -67,63 +98,119 @@ class SettingsDialog(QDialog): theme_layout.addWidget(self.theme_light) theme_layout.addWidget(self.theme_dark) - form.addRow(theme_group) + # 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) - # Add Behaviour - behaviour_group = QGroupBox("Behaviour") - behaviour_layout = QVBoxLayout(behaviour_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) self.move_todos = QCheckBox( - "Move yesterday's unchecked TODOs to today on startup" + strings._("move_unchecked_todos_to_today_on_startup") ) - self.move_todos.setChecked(current_settings.move_todos) + self.move_todos.setChecked(self.current_settings.move_todos) self.move_todos.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.move_todos) - behaviour_layout.addWidget(self.move_todos) - form.addRow(behaviour_group) + self.tags = QCheckBox(strings._("enable_tags_feature")) + self.tags.setChecked(self.current_settings.tags) + self.tags.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.tags) - 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.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) - # Encryption settings - enc_group = QGroupBox("Encryption") + 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")) enc = QVBoxLayout(enc_group) - enc.setContentsMargins(12, 8, 12, 12) - enc.setSpacing(6) - # Checkbox to remember key - self.save_key_btn = QCheckBox("Remember key") - self.key = current_settings.key or "" + self.save_key_btn = QCheckBox(strings._("remember_key")) + self.key = self.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) - # 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 = QLabel(strings._("save_key_warning")) 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() - pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid)) + self.save_key_label.setForegroundRole(QPalette.PlaceholderText) self.save_key_label.setPalette(pal) exp_row = QHBoxLayout() - exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox + exp_row.setContentsMargins(24, 0, 0, 0) exp_row.addWidget(self.save_key_label) enc.addLayout(exp_row) @@ -132,102 +219,77 @@ class SettingsDialog(QDialog): line.setFrameShadow(QFrame.Sunken) enc.addWidget(line) - # Change key button - self.rekey_btn = QPushButton("Change encryption key") + self.rekey_btn = QPushButton(strings._("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) - form.addRow(enc_group) + layout.addWidget(enc_group) - # Privacy settings - priv_group = QGroupBox("Lock screen when idle") + # --- Idle lock group ---------------------------------------------- + priv_group = QGroupBox(strings._("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("Never") + self.idle_spin.setSpecialValueText(strings._("never")) self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15)) priv.addWidget(self.idle_spin, 0, Qt.AlignLeft) - # 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 = QLabel(strings._("autolock_explanation")) 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() - spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid)) + self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText) self.idle_spin_label.setPalette(spal) spin_row = QHBoxLayout() - spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox + spin_row.setContentsMargins(24, 0, 0, 0) spin_row.addWidget(self.idle_spin_label) priv.addLayout(spin_row) - form.addRow(priv_group) + layout.addWidget(priv_group) + layout.addStretch() + return page - # Maintenance settings - maint_group = QGroupBox("Database maintenance") + 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")) maint = QVBoxLayout(maint_group) - maint.setContentsMargins(12, 8, 12, 12) - maint.setSpacing(6) - self.compact_btn = QPushButton("Compact database") + self.compact_btn = QPushButton(strings._("database_compact")) 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) - # Explanation for compating button - self.compact_label = QLabel( - "Compacting runs VACUUM on the database. This can help reduce its size." - ) + self.compact_label = QLabel(strings._("database_compact_explanation")) self.compact_label.setWordWrap(True) self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - # make it look secondary cpal = self.compact_label.palette() - cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid)) + self.compact_label.setForegroundRole(QPalette.PlaceholderText) self.compact_label.setPalette(cpal) maint_row = QHBoxLayout() - maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button + maint_row.setContentsMargins(24, 0, 0, 0) maint_row.addWidget(self.compact_label) maint.addLayout(maint_row) - form.addRow(maint_group) + layout.addWidget(maint_group) + layout.addStretch() + return page - # 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) + # ------------------------------------------------------------------ # + # Save settings + # ------------------------------------------------------------------ # def _save(self): - # Save the selected theme into QSettings if self.theme_dark.isChecked(): selected_theme = Theme.DARK elif self.theme_light.isChecked(): @@ -238,11 +300,16 @@ class SettingsDialog(QDialog): key_to_save = self.key if self.save_key_btn.isChecked() else "" self._cfg = DBConfig( - path=Path(self.path_edit.text()), + path=Path(self.current_settings.path), 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) @@ -250,27 +317,39 @@ class SettingsDialog(QDialog): self.accept() def _change_key(self): - p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key") + p1 = KeyPrompt( + self, + title=strings._("change_encryption_key"), + message=strings._("enter_a_new_encryption_key"), + ) if p1.exec() != QDialog.Accepted: return new_key = p1.key() - p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key") + p2 = KeyPrompt( + self, + title=strings._("change_encryption_key"), + message=strings._("reenter_the_new_key"), + ) if p2.exec() != QDialog.Accepted: return if new_key != p2.key(): - QMessageBox.warning(self, "Key mismatch", "The two entries did not match.") + QMessageBox.warning( + self, strings._("key_mismatch"), strings._("key_mismatch_explanation") + ) return if not new_key: - QMessageBox.warning(self, "Empty key", "Key cannot be empty.") + QMessageBox.warning( + self, strings._("empty_key"), strings._("empty_key_explanation") + ) return try: self.key = new_key self._db.rekey(new_key) QMessageBox.information( - self, "Key changed", "The notebook was re-encrypted with the new key!" + self, strings._("key_changed"), strings._("key_changed_explanation") ) except Exception as e: - QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") + QMessageBox.critical(self, strings._("error"), str(e)) @Slot(bool) def _save_key_btn_clicked(self, checked: bool): @@ -278,7 +357,9 @@ class SettingsDialog(QDialog): if checked: if not self.key: p1 = KeyPrompt( - self, title="Enter your key", message="Enter the encryption key" + self, + title=strings._("unlock_encrypted_notebook_explanation"), + message=strings._("unlock_encrypted_notebook_explanation"), ) if p1.exec() != QDialog.Accepted: self.save_key_btn.blockSignals(True) @@ -292,10 +373,10 @@ class SettingsDialog(QDialog): try: self._db.compact() QMessageBox.information( - self, "Compact complete", "Database compacted successfully!" + self, strings._("success"), strings._("database_compacted_successfully") ) except Exception as e: - QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}") + QMessageBox.critical(self, strings._("error"), str(e)) @property def config(self) -> DBConfig: diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py new file mode 100644 index 0000000..37b5394 --- /dev/null +++ b/bouquin/statistics_dialog.py @@ -0,0 +1,356 @@ +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 (Mon–Sun) + 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() diff --git a/bouquin/strings.py b/bouquin/strings.py new file mode 100644 index 0000000..eff0e18 --- /dev/null +++ b/bouquin/strings.py @@ -0,0 +1,39 @@ +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 diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py new file mode 100644 index 0000000..a5d12d0 --- /dev/null +++ b/bouquin/tag_browser.py @@ -0,0 +1,253 @@ +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() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py new file mode 100644 index 0000000..423bd06 --- /dev/null +++ b/bouquin/tags_widget.py @@ -0,0 +1,259 @@ +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) diff --git a/bouquin/theme.py b/bouquin/theme.py index ddd9fa5..305f249 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -2,8 +2,9 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum from PySide6.QtGui import QPalette, QColor, QGuiApplication -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from PySide6.QtCore import QObject, Signal +from weakref import WeakSet class Theme(Enum): @@ -26,6 +27,9 @@ 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() @@ -35,6 +39,20 @@ 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 @@ -43,28 +61,34 @@ class ThemeManager(QObject): self.apply(theme) def apply(self, theme: Theme): - # Resolve "system" + # Resolve "system" into a concrete theme + resolved = theme if theme == Theme.SYSTEM: - 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 + resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT - # Always use Fusion so palette applies consistently cross-platform - self._app.setStyle("Fusion") - - if theme == Theme.DARK: + if resolved == 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("") - self.themeChanged.emit(theme) + # Always use Fusion so palette applies consistently cross-platform + QApplication.setStyle("Fusion") + + self._app.setPalette(pal) + self._current = resolved + # Re-style any registered widgets + self._restyle_registered() + self.themeChanged.emit(self._current) + + 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) # ----- Palettes ----- def _dark_palette(self) -> QPalette: @@ -75,17 +99,24 @@ 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.Button, window) pal.setColor(QPalette.ButtonText, text) + + # Buttons/frames + pal.setColor(QPalette.Button, window) 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)) @@ -94,11 +125,136 @@ class ThemeManager(QObject): return pal def _light_palette(self) -> QPalette: - # 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 + 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")) + 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() diff --git a/bouquin/time_log.py b/bouquin/time_log.py new file mode 100644 index 0000000..a76ccf6 --- /dev/null +++ b/bouquin/time_log.py @@ -0,0 +1,1217 @@ +from __future__ import annotations + +import csv +import html + +from collections import defaultdict +from typing import Optional +from sqlcipher3.dbapi2 import IntegrityError + +from PySide6.QtCore import Qt, QDate, QUrl +from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout +from PySide6.QtPrintSupport import QPrinter +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QVBoxLayout, + QHBoxLayout, + QWidget, + QFileDialog, + QFormLayout, + QLabel, + QComboBox, + QLineEdit, + QDoubleSpinBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QAbstractItemView, + QHeaderView, + QTabWidget, + QListWidget, + QListWidgetItem, + QDateEdit, + QMessageBox, + QCompleter, + QToolButton, + QSizePolicy, + QStyle, + QInputDialog, +) + +from .db import DBManager +from . import strings + + +class TimeLogWidget(QFrame): + """ + Collapsible per-page time log summary + button to open the full dialog. + Shown in the left sidebar above the Tags widget. + """ + + 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 + open dialog button) + self.toggle_btn = QToolButton() + self.toggle_btn.setText(strings._("time_log")) + 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.open_btn = QToolButton() + self.open_btn.setIcon( + self.style().standardIcon(QStyle.SP_FileDialogDetailedView) + ) + self.open_btn.setToolTip(strings._("open_time_log")) + self.open_btn.setAutoRaise(True) + self.open_btn.clicked.connect(self._open_dialog) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.addWidget(self.toggle_btn) + header.addStretch(1) + header.addWidget(self.open_btn) + + # Body: simple summary label for the day + self.body = QWidget() + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 4, 0, 0) + self.body_layout.setSpacing(4) + + self.summary_label = QLabel(strings._("time_log_no_entries")) + self.summary_label.setWordWrap(True) + self.body_layout.addWidget(self.summary_label) + 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 + self._reload_summary() + if not self.toggle_btn.isChecked(): + self.summary_label.setText(strings._("time_log_collapsed_hint")) + + # ----- internals --------------------------------------------------- + + def _on_toggle(self, checked: bool) -> None: + self.body.setVisible(checked) + self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) + if checked and self._current_date: + self._reload_summary() + + def _update_title(self, total_hours: Optional[float]) -> None: + """Update the header text, optionally including total hours.""" + if total_hours is None: + self.toggle_btn.setText(strings._("time_log")) + else: + self.toggle_btn.setText( + strings._("time_log_with_total").format(hours=total_hours) + ) + + def _reload_summary(self) -> None: + if not self._current_date: + self._update_title(None) + self.summary_label.setText(strings._("time_log_no_date")) + return + + rows = self._db.time_log_for_date(self._current_date) + if not rows: + self._update_title(None) + self.summary_label.setText(strings._("time_log_no_entries")) + return + + total_minutes = sum(r[6] for r in rows) # index 6 = minutes + total_hours = total_minutes / 60.0 + + # Update header with running total (visible even when collapsed) + self._update_title(total_hours) + + # Per-project totals + per_project: dict[str, int] = {} + for _, _, _, project_name, *_rest in rows: + minutes = _rest[2] # activity_id, activity_name, minutes, note + per_project[project_name] = per_project.get(project_name, 0) + minutes + + lines = [strings._("time_log_total_hours").format(hours=total_hours)] + for pname, mins in sorted(per_project.items()): + lines.append(f"- {pname}: {mins/60:.2f}h") + + self.summary_label.setText("\n".join(lines)) + + def _open_dialog(self) -> None: + if not self._current_date: + return + + dlg = TimeLogDialog(self._db, self._current_date, self) + dlg.exec() + + # Always refresh summary + header totals + self._reload_summary() + + if not self.toggle_btn.isChecked(): + self.summary_label.setText(strings._("time_log_collapsed_hint")) + + +class TimeLogDialog(QDialog): + """ + Per-day time log dialog. + + Lets you: + 1) choose a project + 2) enter an activity (free-text with autocomplete) + 3) enter time in decimal hours (0.25 = 15 min, 0.17 ≈ 10 min) + 4) manage entries for this date + """ + + def __init__(self, db: DBManager, date_iso: str, parent=None): + super().__init__(parent) + self._db = db + self._date_iso = date_iso + self._current_entry_id: Optional[int] = None + # Guard flag used when repopulating the table so we don’t treat + # programmatic item changes as user edits. + self._reloading_entries: bool = False + + self.setWindowTitle(strings._("time_log_for").format(date=date_iso)) + self.resize(900, 600) + + root = QVBoxLayout(self) + + # --- Top: date label + root.addWidget(QLabel(strings._("time_log_date_label").format(date=date_iso))) + + # --- Project / activity / hours row + form = QFormLayout() + + # Project + proj_row = QHBoxLayout() + self.project_combo = QComboBox() + self.manage_projects_btn = QPushButton(strings._("manage_projects")) + self.manage_projects_btn.clicked.connect(self._manage_projects) + proj_row.addWidget(self.project_combo, 1) + proj_row.addWidget(self.manage_projects_btn) + form.addRow(strings._("project"), proj_row) + + # Activity (free text with autocomplete) + act_row = QHBoxLayout() + self.activity_edit = QLineEdit() + self.manage_activities_btn = QPushButton(strings._("manage_activities")) + self.manage_activities_btn.clicked.connect(self._manage_activities) + act_row.addWidget(self.activity_edit, 1) + act_row.addWidget(self.manage_activities_btn) + form.addRow(strings._("activity"), act_row) + + # Optional Note + note_row = QHBoxLayout() + self.note = QLineEdit() + note_row.addWidget(self.note, 1) + form.addRow(strings._("note"), note_row) + + # Hours (decimal) + self.hours_spin = QDoubleSpinBox() + self.hours_spin.setRange(0.0, 24.0) + self.hours_spin.setDecimals(2) + self.hours_spin.setSingleStep(0.25) + form.addRow(strings._("hours"), self.hours_spin) + + root.addLayout(form) + + # --- Buttons for entry + btn_row = QHBoxLayout() + self.add_update_btn = QPushButton("&" + strings._("add_time_entry")) + self.add_update_btn.clicked.connect(self._on_add_or_update) + + self.delete_btn = QPushButton("&" + strings._("delete_time_entry")) + self.delete_btn.clicked.connect(self._on_delete_entry) + self.delete_btn.setEnabled(False) + + self.report_btn = QPushButton("&" + strings._("run_report")) + self.report_btn.clicked.connect(self._on_run_report) + + btn_row.addStretch(1) + btn_row.addWidget(self.add_update_btn) + btn_row.addWidget(self.delete_btn) + btn_row.addWidget(self.report_btn) + root.addLayout(btn_row) + + # --- Table of entries for this date + self.table = QTableWidget() + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("activity"), + strings._("note"), + strings._("hours"), + ] + ) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode( + 3, QHeaderView.ResizeToContents + ) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.itemSelectionChanged.connect(self._on_row_selected) + # When a cell is edited inline, commit the change back to the DB. + self.table.itemChanged.connect(self._on_table_item_changed) + root.addWidget(self.table, 1) + + # --- 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) + root.addLayout(close_row) + + # Init data + self._reload_projects() + self._reload_activities() + self._reload_entries() + + # ----- Data loading ------------------------------------------------ + + def _reload_projects(self) -> None: + self.project_combo.clear() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + + def _reload_activities(self) -> None: + activities = [name for _, name in self._db.list_activities()] + completer = QCompleter(activities, self.activity_edit) + completer.setCaseSensitivity(Qt.CaseInsensitive) + self.activity_edit.setCompleter(completer) + + def _reload_entries(self) -> None: + """Reload the table from the database. + + While we are repopulating the QTableWidget we temporarily disable the + itemChanged handler so that programmatic changes do not get written + back to the database. + """ + self._reloading_entries = True + try: + rows = self._db.time_log_for_date(self._date_iso) + self.table.setRowCount(len(rows)) + for row_idx, r in enumerate(rows): + entry_id = r[0] + project_name = r[3] + activity_name = r[5] + note = r[7] or "" + minutes = r[6] + hours = minutes / 60.0 + + item_proj = QTableWidgetItem(project_name) + item_act = QTableWidgetItem(activity_name) + item_note = QTableWidgetItem(note) + item_hours = QTableWidgetItem(f"{hours:.2f}") + + # store the entry id on the first column + item_proj.setData(Qt.ItemDataRole.UserRole, entry_id) + + self.table.setItem(row_idx, 0, item_proj) + self.table.setItem(row_idx, 1, item_act) + self.table.setItem(row_idx, 2, item_note) + self.table.setItem(row_idx, 3, item_hours) + finally: + self._reloading_entries = False + + self._current_entry_id = None + self.delete_btn.setEnabled(False) + self.add_update_btn.setText("&" + strings._("add_time_entry")) + + # ----- Actions ----------------------------------------------------- + + def _ensure_project_id(self) -> Optional[int]: + """Get selected project_id from combo.""" + idx = self.project_combo.currentIndex() + if idx < 0: + return None + proj_id = self.project_combo.itemData(idx) + return int(proj_id) if proj_id is not None else None + + def _on_add_or_update(self) -> None: + proj_id = self._ensure_project_id() + if proj_id is None: + QMessageBox.warning( + self, + strings._("project_required_title"), + strings._("project_required_message"), + ) + return + + activity_name = self.activity_edit.text().strip() + if not activity_name: + QMessageBox.warning( + self, + strings._("activity_required_title"), + strings._("activity_required_message"), + ) + return + + note = self.note.text().strip() + if not note: + note = None + + hours = float(self.hours_spin.value()) + minutes = int(round(hours * 60)) + + # Create activity if needed + activity_id = self._db.add_activity(activity_name) + + if self._current_entry_id is None: + # New entry + self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes, note) + else: + # Update existing + self._db.update_time_log( + self._current_entry_id, proj_id, activity_id, minutes, note + ) + + self._reload_entries() + + def _on_row_selected(self) -> None: + items = self.table.selectedItems() + if not items: + self._current_entry_id = None + self.delete_btn.setEnabled(False) + self.add_update_btn.setText("&" + strings._("add_time_entry")) + return + + row = items[0].row() + proj_item = self.table.item(row, 0) + act_item = self.table.item(row, 1) + note_item = self.table.item(row, 2) + hours_item = self.table.item(row, 3) + entry_id = proj_item.data(Qt.ItemDataRole.UserRole) + + self._current_entry_id = int(entry_id) + self.delete_btn.setEnabled(True) + self.add_update_btn.setText("&" + strings._("update_time_entry")) + + # push values into the editors + proj_name = proj_item.text() + act_name = act_item.text() + note = note_item.text() + hours = float(hours_item.text()) + + # Set project combo by name + idx = self.project_combo.findText(proj_name, Qt.MatchFixedString) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + + self.activity_edit.setText(act_name) + self.note.setText(note) + self.hours_spin.setValue(hours) + + def _on_table_item_changed(self, item: QTableWidgetItem) -> None: + """Commit inline edits in the table back to the database. + + Editing a cell should behave like selecting that row and pressing + the Add/Update button, so we reuse the same validation and DB logic. + """ + if self._reloading_entries: + # Ignore changes that come from _reload_entries(). + return + + if item is None: # pragma: no cover + return + + row = item.row() + + proj_item = self.table.item(row, 0) + act_item = self.table.item(row, 1) + note_item = self.table.item(row, 2) + hours_item = self.table.item(row, 3) + + if proj_item is None or act_item is None or hours_item is None: + # Incomplete row – nothing to do. + return + + # Recover the entry id from the hidden UserRole on the project cell + entry_id = proj_item.data(Qt.ItemDataRole.UserRole) + self._current_entry_id = int(entry_id) if entry_id is not None else None + + # Push values into the editors (similar to _on_row_selected). + proj_name = proj_item.text() + act_name = act_item.text() + note_text = note_item.text() if note_item is not None else "" + hours_text = hours_item.text() + + # Set project combo by name, creating a project on the fly if needed. + idx = self.project_combo.findText(proj_name, Qt.MatchFixedString) + if idx < 0 and proj_name: + # Allow creating a new project directly from the table. + proj_id = self._db.add_project(proj_name) + self._reload_projects() + idx = self.project_combo.findData(proj_id) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + else: + self.project_combo.setCurrentIndex(-1) + + self.activity_edit.setText(act_name) + self.note.setText(note_text) + + # Parse hours; if invalid, show the same style of warning as elsewhere. + try: + hours = float(hours_text) + except ValueError: + QMessageBox.warning( + self, + strings._("invalid_time_title"), + strings._("invalid_time_message"), + ) + # Reset table back to the last known-good state. + self._reload_entries() + return + + self.hours_spin.setValue(hours) + + # Mirror button state to reflect whether we're updating or adding. + if self._current_entry_id is None: + self.delete_btn.setEnabled(False) + self.add_update_btn.setText(strings._("add_time_entry")) + else: + self.delete_btn.setEnabled(True) + self.add_update_btn.setText(strings._("update_time_entry")) + + # Finally, reuse the existing validation + DB logic. + self._on_add_or_update() + + def _on_delete_entry(self) -> None: + if self._current_entry_id is None: + return + self._db.delete_time_log(self._current_entry_id) + self._reload_entries() + + def _on_run_report(self) -> None: + dlg = TimeReportDialog(self._db, self) + dlg.exec() + + # ----- Project / activity management ------------------------------- + + def _manage_projects(self) -> None: + dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self) + dlg.exec() + self._reload_projects() + + def _manage_activities(self) -> None: + dlg = TimeCodeManagerDialog(self._db, focus_tab="activities", parent=self) + dlg.exec() + self._reload_activities() + + +class TimeCodeManagerDialog(QDialog): + """ + Dialog to manage projects and activities, similar spirit to Tag Browser: + Add / rename / delete without having to log time. + """ + + def __init__(self, db: DBManager, focus_tab: str = "projects", parent=None): + super().__init__(parent) + self._db = db + + self.setWindowTitle(strings._("manage_projects_activities")) + self.resize(500, 400) + + root = QVBoxLayout(self) + + self.tabs = QTabWidget() + root.addWidget(self.tabs, 1) + + # Projects tab + proj_tab = QWidget() + proj_layout = QVBoxLayout(proj_tab) + self.project_list = QListWidget() + proj_layout.addWidget(self.project_list, 1) + + proj_btn_row = QHBoxLayout() + self.proj_add_btn = QPushButton("&" + strings._("add_project")) + self.proj_rename_btn = QPushButton("&" + strings._("rename_project")) + self.proj_delete_btn = QPushButton("&" + strings._("delete_project")) + proj_btn_row.addWidget(self.proj_add_btn) + proj_btn_row.addWidget(self.proj_rename_btn) + proj_btn_row.addWidget(self.proj_delete_btn) + proj_layout.addLayout(proj_btn_row) + + self.tabs.addTab(proj_tab, "&" + strings._("projects")) + + # Activities tab + act_tab = QWidget() + act_layout = QVBoxLayout(act_tab) + self.activity_list = QListWidget() + act_layout.addWidget(self.activity_list, 1) + + act_btn_row = QHBoxLayout() + self.act_add_btn = QPushButton("&" + strings._("add_activity")) + self.act_rename_btn = QPushButton("&" + strings._("rename_activity")) + self.act_delete_btn = QPushButton("&" + strings._("delete_activity")) + act_btn_row.addWidget(self.act_add_btn) + act_btn_row.addWidget(self.act_rename_btn) + act_btn_row.addWidget(self.act_delete_btn) + act_layout.addLayout(act_btn_row) + + self.tabs.addTab(act_tab, strings._("activities")) + + # Close + close_row = QHBoxLayout() + close_row.addStretch(1) + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + close_row.addWidget(close_btn) + root.addLayout(close_row) + + # Wire + self.proj_add_btn.clicked.connect(self._add_project) + self.proj_rename_btn.clicked.connect(self._rename_project) + self.proj_delete_btn.clicked.connect(self._delete_project) + + self.act_add_btn.clicked.connect(self._add_activity) + self.act_rename_btn.clicked.connect(self._rename_activity) + self.act_delete_btn.clicked.connect(self._delete_activity) + + # Initial data + self._reload_projects() + self._reload_activities() + + if focus_tab == "activities": + self.tabs.setCurrentIndex(1) + + def _reload_projects(self): + self.project_list.clear() + for proj_id, name in self._db.list_projects(): + item = QListWidgetItem(name) + item.setData(Qt.ItemDataRole.UserRole, proj_id) + self.project_list.addItem(item) + + def _reload_activities(self): + self.activity_list.clear() + for act_id, name in self._db.list_activities(): + item = QListWidgetItem(name) + item.setData(Qt.ItemDataRole.UserRole, act_id) + self.activity_list.addItem(item) + + # ---------- helpers ------------------------------------------------ + + def _prompt_name( + self, + title_key: str, + label_key: str, + default: str = "", + ) -> tuple[str, bool]: + """Wrapper around QInputDialog.getText with i18n keys.""" + title = strings._(title_key) + label = strings._(label_key) + text, ok = QInputDialog.getText( + self, + title, + label, + QLineEdit.EchoMode.Normal, + default, + ) + return text.strip(), ok + + def _selected_project_item(self) -> QListWidgetItem | None: + items = self.project_list.selectedItems() + return items[0] if items else None + + def _selected_activity_item(self) -> QListWidgetItem | None: + items = self.activity_list.selectedItems() + return items[0] if items else None + + # ---------- projects ----------------------------------------------- + + def _add_project(self) -> None: + name, ok = self._prompt_name( + "add_project_title", + "add_project_label", + "", + ) + if not ok or not name: + return + + try: + self._db.add_project(name) + except ValueError: + # Empty / invalid name – nothing to do, but be defensive + QMessageBox.warning( + self, + strings._("invalid_project_title"), + strings._("invalid_project_message"), + ) + return + + self._reload_projects() + + def _rename_project(self) -> None: + item = self._selected_project_item() + if item is None: + QMessageBox.information( + self, + strings._("select_project_title"), + strings._("select_project_message"), + ) + return + + old_name = item.text() + proj_id = int(item.data(Qt.ItemDataRole.UserRole)) + + new_name, ok = self._prompt_name( + "rename_project_title", + "rename_project_label", + old_name, + ) + if not ok or not new_name or new_name == old_name: + return + + try: + self._db.rename_project(proj_id, new_name) + except IntegrityError as exc: + QMessageBox.warning( + self, + strings._("project_rename_error_title"), + strings._("project_rename_error_message").format(error=str(exc)), + ) + return + + self._reload_projects() + + def _delete_project(self) -> None: + item = self._selected_project_item() + if item is None: + QMessageBox.information( + self, + strings._("select_project_title"), + strings._("select_project_message"), + ) + return + + proj_id = int(item.data(Qt.ItemDataRole.UserRole)) + name = item.text() + + resp = QMessageBox.question( + self, + strings._("delete_project_title"), + strings._("delete_project_confirm").format(name=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if resp != QMessageBox.StandardButton.Yes: + return + + try: + self._db.delete_project(proj_id) + except IntegrityError: + # Likely FK constraint: project has time entries + QMessageBox.warning( + self, + strings._("project_delete_error_title"), + strings._("project_delete_error_message"), + ) + return + + self._reload_projects() + + # ---------- activities --------------------------------------------- + + def _add_activity(self) -> None: + name, ok = self._prompt_name( + "add_activity_title", + "add_activity_label", + "", + ) + if not ok or not name: + return + + try: + self._db.add_activity(name) + except ValueError: + QMessageBox.warning( + self, + strings._("invalid_activity_title"), + strings._("invalid_activity_message"), + ) + return + + self._reload_activities() + + def _rename_activity(self) -> None: + item = self._selected_activity_item() + if item is None: + QMessageBox.information( + self, + strings._("select_activity_title"), + strings._("select_activity_message"), + ) + return + + old_name = item.text() + act_id = int(item.data(Qt.ItemDataRole.UserRole)) + + new_name, ok = self._prompt_name( + "rename_activity_title", + "rename_activity_label", + old_name, + ) + if not ok or not new_name or new_name == old_name: + return + + try: + self._db.rename_activity(act_id, new_name) + except IntegrityError as exc: + QMessageBox.warning( + self, + strings._("activity_rename_error_title"), + strings._("activity_rename_error_message").format(error=str(exc)), + ) + return + + self._reload_activities() + + def _delete_activity(self) -> None: + item = self._selected_activity_item() + if item is None: + QMessageBox.information( + self, + strings._("select_activity_title"), + strings._("select_activity_message"), + ) + return + + act_id = int(item.data(Qt.ItemDataRole.UserRole)) + name = item.text() + + resp = QMessageBox.question( + self, + strings._("delete_activity_title"), + strings._("delete_activity_confirm").format(name=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if resp != QMessageBox.StandardButton.Yes: + return + + try: + self._db.delete_activity(act_id) + except IntegrityError: + # Activity is referenced by time_log + QMessageBox.warning( + self, + strings._("activity_delete_error_title"), + strings._("activity_delete_error_message"), + ) + return + + self._reload_activities() + + +class TimeReportDialog(QDialog): + """ + Simple report: choose project + date range + granularity (day/week/month). + Shows decimal hours per time period. + """ + + def __init__(self, db: DBManager, parent=None): + super().__init__(parent) + self._db = db + + # state for last run + self._last_rows: list[tuple[str, str, int]] = [] + self._last_total_minutes: int = 0 + self._last_project_name: str = "" + self._last_start: str = "" + self._last_end: str = "" + self._last_gran_label: str = "" + + self.setWindowTitle(strings._("time_log_report")) + self.resize(600, 400) + + root = QVBoxLayout(self) + + form = QFormLayout() + # Project + self.project_combo = QComboBox() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + form.addRow(strings._("project"), self.project_combo) + + # Date range + today = QDate.currentDate() + self.from_date = QDateEdit(today.addDays(-7)) + self.from_date.setCalendarPopup(True) + self.to_date = QDateEdit(today) + self.to_date.setCalendarPopup(True) + + range_row = QHBoxLayout() + range_row.addWidget(self.from_date) + range_row.addWidget(QLabel("—")) + range_row.addWidget(self.to_date) + form.addRow(strings._("date_range"), range_row) + + # Granularity + self.granularity = QComboBox() + self.granularity.addItem(strings._("by_day"), "day") + self.granularity.addItem(strings._("by_week"), "week") + self.granularity.addItem(strings._("by_month"), "month") + form.addRow(strings._("group_by"), self.granularity) + + root.addLayout(form) + + # Run and + export buttons + run_row = QHBoxLayout() + run_btn = QPushButton(strings._("run_report")) + run_btn.clicked.connect(self._run_report) + + export_btn = QPushButton(strings._("export_csv")) + export_btn.clicked.connect(self._export_csv) + + pdf_btn = QPushButton(strings._("export_pdf")) + pdf_btn.clicked.connect(self._export_pdf) + + run_row.addStretch(1) + run_row.addWidget(run_btn) + run_row.addWidget(export_btn) + run_row.addWidget(pdf_btn) + root.addLayout(run_row) + + # Table + self.table = QTableWidget() + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels( + [ + strings._("time_period"), + strings._("activity"), + strings._("note"), + strings._("hours"), + ] + ) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode( + 3, QHeaderView.ResizeToContents + ) + root.addWidget(self.table, 1) + + # Total label + self.total_label = QLabel("") + root.addWidget(self.total_label) + + # Close + close_row = QHBoxLayout() + close_row.addStretch(1) + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + close_row.addWidget(close_btn) + root.addLayout(close_row) + + def _run_report(self): + idx = self.project_combo.currentIndex() + if idx < 0: + return + proj_id = int(self.project_combo.itemData(idx)) + + start = self.from_date.date().toString("yyyy-MM-dd") + end = self.to_date.date().toString("yyyy-MM-dd") + gran = self.granularity.currentData() + + # Keep human-friendly copies for PDF header + self._last_project_name = self.project_combo.currentText() + self._last_start = start + self._last_end = end + self._last_gran_label = self.granularity.currentText() + + rows = self._db.time_report(proj_id, start, end, gran) + + self._last_rows = rows + self._last_total_minutes = sum(r[3] for r in rows) + + self.table.setRowCount(len(rows)) + for i, (time_period, activity_name, note, minutes) in enumerate(rows): + hrs = minutes / 60.0 + self.table.setItem(i, 0, QTableWidgetItem(time_period)) + self.table.setItem(i, 1, QTableWidgetItem(activity_name)) + self.table.setItem(i, 2, QTableWidgetItem(note)) + self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) + + total_hours = self._last_total_minutes / 60.0 + self.total_label.setText( + strings._("time_report_total").format(hours=total_hours) + ) + + def _export_csv(self): + if not self._last_rows: + QMessageBox.information( + self, + strings._("no_report_title"), + strings._("no_report_message"), + ) + return + + filename, _ = QFileDialog.getSaveFileName( + self, + strings._("export_csv"), + "", + "CSV Files (*.csv);;All Files (*)", + ) + if not filename: + return + + try: + with open(filename, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + + # Header + writer.writerow( + [ + strings._("time_period"), + strings._("activity"), + strings._("note"), + strings._("hours"), + ] + ) + + # Data rows + for time_period, activity_name, note, minutes in self._last_rows: + hours = minutes / 60.0 + writer.writerow([time_period, activity_name, note, f"{hours:.2f}"]) + + # Blank line + total + total_hours = self._last_total_minutes / 60.0 + writer.writerow([]) + writer.writerow([strings._("total"), "", f"{total_hours:.2f}"]) + except OSError as exc: + QMessageBox.warning( + self, + strings._("export_csv_error_title"), + strings._("export_csv_error_message").format(error=str(exc)), + ) + + def _export_pdf(self): + if not self._last_rows: + QMessageBox.information( + self, + strings._("no_report_title"), + strings._("no_report_message"), + ) + return + + filename, _ = QFileDialog.getSaveFileName( + self, + strings._("export_pdf"), + "", + "PDF Files (*.pdf);;All Files (*)", + ) + if not filename: + return + + # ---------- Build chart image (hours per period) ---------- + per_period_minutes: dict[str, int] = defaultdict(int) + for period, _activity, note, minutes in self._last_rows: + per_period_minutes[period] += minutes + + periods = sorted(per_period_minutes.keys()) + chart_w, chart_h = 800, 220 + chart = QImage(chart_w, chart_h, QImage.Format_ARGB32) + chart.fill(Qt.white) + + if periods: + painter = QPainter(chart) + try: + painter.setRenderHint(QPainter.Antialiasing, True) + + margin = 50 + left = margin + 30 # extra space for Y labels + top = margin + right = chart_w - margin + bottom = chart_h - margin - 20 # room for X labels + width = right - left + height = bottom - top + + painter.setPen(Qt.black) + + # Y-axis label "Hours" above the axis + painter.drawText( + left - 50, # left of the axis + top - 30, # higher up so it doesn't touch the axis line + 50, + 20, + Qt.AlignRight | Qt.AlignVCenter, + strings._("hours"), + ) + + # Border + painter.drawRect(left, top, width, height) + + max_hours = max(per_period_minutes[p] for p in periods) / 60.0 + if max_hours > 0: + n = len(periods) + bar_spacing = width / max(1, n) + bar_width = bar_spacing * 0.6 + + # Y-axis ticks (0, 1/3, 2/3, max) + num_ticks = 3 + for i in range(num_ticks + 1): + val = max_hours * i / num_ticks + y_tick = bottom - int((val / max_hours) * height) + # small tick mark + painter.drawLine(left - 5, y_tick, left, y_tick) + # label to the left + painter.drawText( + left - 40, + y_tick - 7, + 35, + 14, + Qt.AlignRight | Qt.AlignVCenter, + f"{val:.1f}", + ) + + # Bars + painter.setBrush(QColor(80, 140, 200)) + painter.setPen(Qt.NoPen) + + for i, period in enumerate(periods): + hours = per_period_minutes[period] / 60.0 + bar_h = int((hours / max_hours) * (height - 10)) + if bar_h <= 0: + continue # pragma: no cover + + x_center = left + bar_spacing * (i + 0.5) + x = int(x_center - bar_width / 2) + y_top_bar = bottom - bar_h + + painter.drawRect(x, y_top_bar, int(bar_width), bar_h) + + # X labels after bars, in black + painter.setPen(Qt.black) + for i, period in enumerate(periods): + x_center = left + bar_spacing * (i + 0.5) + x = int(x_center - bar_width / 2) + painter.drawText( + x, + bottom + 5, + int(bar_width), + 20, + Qt.AlignHCenter | Qt.AlignTop, + period, + ) + finally: + painter.end() + + # ---------- Build HTML report ---------- + project = html.escape(self._last_project_name or "") + start = html.escape(self._last_start or "") + end = html.escape(self._last_end or "") + gran = html.escape(self._last_gran_label or "") + + total_hours = self._last_total_minutes / 60.0 + + # Table rows (period, activity, hours) + row_html_parts: list[str] = [] + for period, activity, note, minutes in self._last_rows: + hours = minutes / 60.0 + row_html_parts.append( + "" + f"{html.escape(period)}" + f"{html.escape(activity)}" + f"{hours:.2f}" + "" + ) + rows_html = "\n".join(row_html_parts) + + html_doc = f""" + + + + + + + +

{html.escape(strings._("time_log_report_title").format(project=project))}

+

+ {html.escape(strings._("time_log_report_meta").format( + start=start, end=end, granularity=gran))} +

+

+ + + + + + + {rows_html} +
{html.escape(strings._("time_period"))}{html.escape(strings._("activity"))}{html.escape(strings._("hours"))}
+

{html.escape(strings._("time_report_total").format(hours=total_hours))}

+ + +""" + + # ---------- Render HTML to PDF ---------- + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(filename) + printer.setPageOrientation(QPageLayout.Orientation.Landscape) + + doc = QTextDocument() + # attach the chart image as a resource + doc.addResource(QTextDocument.ImageResource, QUrl("chart"), chart) + doc.setHtml(html_doc) + + try: + doc.print_(printer) + except Exception as exc: # very defensive + QMessageBox.warning( + self, + strings._("export_pdf_error_title"), + strings._("export_pdf_error_message").format(error=str(exc)), + ) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 7b0f248..8873ffd 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -4,128 +4,128 @@ 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() italicRequested = Signal() - underlineRequested = Signal() strikeRequested = Signal() codeRequested = Signal() headingRequested = Signal(int) bulletsRequested = Signal() numbersRequested = Signal() checkboxesRequested = Signal() - alignRequested = Signal(Qt.AlignmentFlag) historyRequested = Signal() insertImageRequested = Signal() + alarmRequested = Signal() + timerRequested = Signal() + fontSizeLargerRequested = Signal() + fontSizeSmallerRequested = Signal() def __init__(self, parent=None): - super().__init__("Format", parent) - self.setObjectName("Format") + super().__init__(strings._("toolbar_format"), parent) + self.setObjectName(strings._("toolbar_format")) self.setToolButtonStyle(Qt.ToolButtonTextOnly) self._build_actions() self._apply_toolbar_styles() def _build_actions(self): self.actBold = QAction("B", self) - self.actBold.setToolTip("Bold") + self.actBold.setToolTip(strings._("toolbar_bold")) self.actBold.setCheckable(True) self.actBold.setShortcut(QKeySequence.Bold) self.actBold.triggered.connect(self.boldRequested) self.actItalic = QAction("I", self) - self.actItalic.setToolTip("Italic") + self.actItalic.setToolTip(strings._("toolbar_italic")) self.actItalic.setCheckable(True) self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.triggered.connect(self.italicRequested) - self.actUnderline = QAction("U", self) - self.actUnderline.setToolTip("Underline") - self.actUnderline.setCheckable(True) - self.actUnderline.setShortcut(QKeySequence.Underline) - self.actUnderline.triggered.connect(self.underlineRequested) - self.actStrike = QAction("S", self) - self.actStrike.setToolTip("Strikethrough") + self.actStrike.setToolTip(strings._("toolbar_strikethrough")) self.actStrike.setCheckable(True) self.actStrike.setShortcut("Ctrl+-") self.actStrike.triggered.connect(self.strikeRequested) self.actCode = QAction("", self) - self.actCode.setToolTip("Code block") + self.actCode.setToolTip(strings._("toolbar_code_block")) self.actCode.setShortcut("Ctrl+`") self.actCode.triggered.connect(self.codeRequested) # Headings self.actH1 = QAction("H1", self) - self.actH1.setToolTip("Heading 1") + self.actH1.setToolTip(strings._("toolbar_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("Heading 2") + self.actH2.setToolTip(strings._("toolbar_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("Heading 3") + self.actH3.setToolTip(strings._("toolbar_heading") + " 3") self.actH3.setCheckable(True) self.actH3.setShortcut("Ctrl+3") self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) - self.actNormal = QAction("N", self) - self.actNormal.setToolTip("Normal paragraph text") + self.actNormal = QAction("P", self) + self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text")) self.actNormal.setCheckable(True) - self.actNormal.setShortcut("Ctrl+N") + self.actNormal.setShortcut("Ctrl+.") 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("Bulleted list") + self.actBullets.setToolTip(strings._("toolbar_bulleted_list")) self.actBullets.setCheckable(True) self.actBullets.triggered.connect(self.bulletsRequested) self.actNumbers = QAction("1.", self) - self.actNumbers.setToolTip("Numbered list") + self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) self.actCheckboxes = QAction("☐", self) - self.actCheckboxes.setToolTip("Toggle checkboxes") + self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images - self.actInsertImg = QAction("Image", self) - self.actInsertImg.setToolTip("Insert image") + self.actInsertImg = QAction("📸", self) + self.actInsertImg.setToolTip(strings._("insert_images")) self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.triggered.connect(self.insertImageRequested) - # Alignment - self.actAlignL = QAction("L", self) - self.actAlignL.setToolTip("Align Left") - self.actAlignL.setCheckable(True) - self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft)) - self.actAlignC = QAction("C", self) - self.actAlignC.setToolTip("Align Center") - self.actAlignC.setCheckable(True) - self.actAlignC.triggered.connect( - lambda: self.alignRequested.emit(Qt.AlignHCenter) - ) - self.actAlignR = QAction("R", self) - self.actAlignR.setToolTip("Align Right") - self.actAlignR.setCheckable(True) - self.actAlignR.triggered.connect( - lambda: self.alignRequested.emit(Qt.AlignRight) - ) - # History button - self.actHistory = QAction("History", self) + self.actHistory = QAction("🔁", self) + self.actHistory.setToolTip(strings._("history")) 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) for a in ( self.actBold, self.actItalic, - self.actUnderline, self.actStrike, self.actH1, self.actH2, @@ -135,11 +135,6 @@ class ToolBar(QToolBar): a.setCheckable(True) a.setActionGroup(self.grpHeadings) - self.grpAlign = QActionGroup(self) - self.grpAlign.setExclusive(True) - for a in (self.actAlignL, self.actAlignC, self.actAlignR): - a.setActionGroup(self.grpAlign) - self.grpLists = QActionGroup(self) self.grpLists.setExclusive(True) for a in (self.actBullets, self.actNumbers, self.actCheckboxes): @@ -150,20 +145,20 @@ class ToolBar(QToolBar): [ self.actBold, self.actItalic, - self.actUnderline, self.actStrike, self.actCode, self.actH1, self.actH2, self.actH3, self.actNormal, + self.actFontSmaller, + self.actFontLarger, self.actBullets, self.actNumbers, self.actCheckboxes, self.actInsertImg, - self.actAlignL, - self.actAlignC, - self.actAlignR, + self.actAlarm, + self.actTimer, self.actHistory, ] ) @@ -171,7 +166,6 @@ class ToolBar(QToolBar): def _apply_toolbar_styles(self): self._style_letter_button(self.actBold, "B", bold=True) self._style_letter_button(self.actItalic, "I", italic=True) - self._style_letter_button(self.actUnderline, "U", underline=True) self._style_letter_button(self.actStrike, "S", strike=True) # Monospace look for code; use a fixed font code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) @@ -181,19 +175,19 @@ 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, "N") + self._style_letter_button(self.actNormal, "P") + self._style_letter_button(self.actFontSmaller, "P-") + self._style_letter_button(self.actFontLarger, "P+") # Lists self._style_letter_button(self.actBullets, "•") self._style_letter_button(self.actNumbers, "1.") - - # Alignment - self._style_letter_button(self.actAlignL, "L") - self._style_letter_button(self.actAlignC, "C") - self._style_letter_button(self.actAlignR, "R") + 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, "View History") + self._style_letter_button(self.actHistory, "🔁") def _style_letter_button( self, diff --git a/bouquin/version_check.py b/bouquin/version_check.py new file mode 100644 index 0000000..b2010d5 --- /dev/null +++ b/bouquin/version_check.py @@ -0,0 +1,412 @@ +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), + ) diff --git a/find_unused_strings.py b/find_unused_strings.py new file mode 100755 index 0000000..5341001 --- /dev/null +++ b/find_unused_strings.py @@ -0,0 +1,259 @@ +#!/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() diff --git a/poetry.lock b/poetry.lock index 87acb50..b968699 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,26 +1,137 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] -name = "beautifulsoup4" -version = "4.14.2" -description = "Screen-scraping library" +name = "certifi" +version = "2025.11.12" +description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" files = [ - {file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"}, - {file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"}, + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] -[package.dependencies] -soupsieve = ">1.2" -typing-extensions = ">=4.0.0" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] +[[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" @@ -35,115 +146,103 @@ files = [ [[package]] name = "coverage" -version = "7.10.7" +version = "7.12.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {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"}, + {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"}, ] [package.dependencies] @@ -152,6 +251,20 @@ 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" @@ -170,30 +283,44 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} test = ["pytest (>=6)"] [[package]] -name = "iniconfig" -version = "2.1.0" -description = "brain-dead simple config-ini parsing" +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[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 = "markdownify" -version = "1.2.0" -description = "Convert HTML to markdown." +name = "markdown" +version = "3.10" +description = "Python implementation of John Gruber's Markdown." optional = false -python-versions = "*" +python-versions = ">=3.10" files = [ - {file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"}, - {file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"}, + {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, + {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, ] -[package.dependencies] -beautifulsoup4 = ">=4.9,<5" -six = ">=1.15,<2" +[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" @@ -235,6 +362,22 @@ 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" @@ -368,6 +511,27 @@ 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" @@ -382,28 +546,6 @@ files = [ {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, ] -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "soupsieve" -version = "2.8" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.9" -files = [ - {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"}, - {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"}, -] - [[package]] name = "sqlcipher3-wheels" version = "0.5.5.post0" @@ -597,7 +739,24 @@ 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.9,<3.14" -content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d" +python-versions = ">=3.10,<3.14" +content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df" diff --git a/pyproject.toml b/pyproject.toml index 11c5a6a..ce8e44a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,20 @@ [tool.poetry] name = "bouquin" -version = "0.1.12.1" +version = "0.5.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] 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.9,<3.14" +python = ">=3.10,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" -markdownify = "^1.2.0" +requests = "^2.32.5" +markdown = "^3.10" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" @@ -22,6 +25,20 @@ 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" diff --git a/release.sh b/release.sh index f3ca3bb..a7e9c28 100755 --- a/release.sh +++ b/release.sh @@ -1,10 +1,19 @@ #!/bin/bash -set -e +set -eo pipefail 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." diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index e0843e5..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/screenshot_dark.png b/screenshot_dark.png deleted file mode 100644 index e9b4b8c..0000000 Binary files a/screenshot_dark.png and /dev/null differ diff --git a/screenshots/history_diff.png b/screenshots/history_diff.png new file mode 100644 index 0000000..46c4591 Binary files /dev/null and b/screenshots/history_diff.png differ diff --git a/screenshots/history_preview.png b/screenshots/history_preview.png new file mode 100644 index 0000000..4475d94 Binary files /dev/null and b/screenshots/history_preview.png differ diff --git a/screenshots/screenshot.png b/screenshots/screenshot.png new file mode 100644 index 0000000..a375c45 Binary files /dev/null and b/screenshots/screenshot.png differ diff --git a/screenshots/statistics.png b/screenshots/statistics.png new file mode 100644 index 0000000..9feb1c4 Binary files /dev/null and b/screenshots/statistics.png differ diff --git a/screenshots/tags.png b/screenshots/tags.png new file mode 100644 index 0000000..7330f08 Binary files /dev/null and b/screenshots/tags.png differ diff --git a/screenshots/time.png b/screenshots/time.png new file mode 100644 index 0000000..436dd8e Binary files /dev/null and b/screenshots/time.png differ diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py index d9ecc99..658b7e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,133 +1,60 @@ import os import sys from pathlib import Path -import pytest -from PySide6.QtCore import QStandardPaths -from tests.qt_helpers import AutoResponder -# Force Qt *non-native* file dialog so we can type a filename programmatically. -os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") -# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env +import pytest +from PySide6.QtWidgets import QApplication + +# Ensure the nested package directory (repo_root/bouquin) is on sys.path +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PKG_PARENT = PROJECT_ROOT / "bouquin" +if str(PKG_PARENT) not in sys.path: + sys.path.insert(0, str(PKG_PARENT)) + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -# Make project importable -from PySide6.QtWidgets import QApplication, QWidget -from bouquin.theme import ThemeManager, ThemeConfig, Theme - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +@pytest.fixture(scope="session") +def app(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app @pytest.fixture(scope="session", autouse=True) -def enable_qstandardpaths_test_mode(): - QStandardPaths.setTestModeEnabled(True) - - -@pytest.fixture() -def temp_home(tmp_path, monkeypatch): - home = tmp_path / "home" - (home / "Documents").mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("HOME", str(home)) - return home - - -@pytest.fixture() -def clean_settings(): - try: - from bouquin.settings import APP_NAME, APP_ORG - from PySide6.QtCore import QSettings - except Exception: - yield - return - s = QSettings(APP_ORG, APP_NAME) - s.clear() +def isolate_qsettings(tmp_path_factory): + cfgdir = tmp_path_factory.mktemp("qt_cfg") + os.environ["XDG_CONFIG_HOME"] = str(cfgdir) yield - s.clear() - - -@pytest.fixture(autouse=True) -def auto_accept_common_dialogs(qtbot): - ar = AutoResponder() - ar.start() - try: - yield - finally: - ar.stop() - - -@pytest.fixture() -def open_window(qtbot, temp_home, clean_settings): - """Launch the app and immediately satisfy first-run/unlock key prompts.""" - from bouquin.main_window import MainWindow - - app = QApplication.instance() - themes = ThemeManager(app, ThemeConfig()) - themes.apply(Theme.SYSTEM) - win = MainWindow(themes=themes) - qtbot.addWidget(win) - win.show() - qtbot.waitExposed(win) - - # Immediately satisfy first-run 'Set key' or 'Unlock' prompts if already visible - AutoResponder().prehandle_key_prompts_if_present() - return win - - -@pytest.fixture() -def today_iso(): - from datetime import date - - d = date.today() - return f"{d.year:04d}-{d.month:02d}-{d.day:02d}" @pytest.fixture -def theme_parent_widget(qtbot): - """A minimal parent that provides .themes.apply(...) like MainWindow.""" - - class _ThemesStub: - def __init__(self): - self.applied = [] - - def apply(self, theme): - self.applied.append(theme) - - class _Parent(QWidget): - def __init__(self): - super().__init__() - self.themes = _ThemesStub() - - parent = _Parent() - qtbot.addWidget(parent) - return parent - - -@pytest.fixture(scope="session") -def qapp(): - from PySide6.QtWidgets import QApplication - - app = QApplication.instance() or QApplication([]) - yield app - # do not quit; pytest might still need it - # app.quit() - - -@pytest.fixture -def temp_db_path(tmp_path): - return tmp_path / "notebook.db" - - -@pytest.fixture -def cfg(temp_db_path): - # Use the real DBConfig from the app (SQLCipher-backed) +def tmp_db_cfg(tmp_path): from bouquin.db import DBConfig + default_db = tmp_path / "notebook.db" + key = "test-secret-key" return DBConfig( - path=Path(temp_db_path), - key="testkey", + path=default_db, + key=key, idle_minutes=0, - theme="system", + theme="light", move_todos=True, + tags=True, + time_log=True, + reminders=True, + locale="en", + font_size=11, ) + + +@pytest.fixture +def fresh_db(tmp_db_cfg): + from bouquin.db import DBManager + + db = DBManager(tmp_db_cfg) + ok = db.connect() + assert ok, "DB connect() should succeed" + yield db + db.close() diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py deleted file mode 100644 index f228177..0000000 --- a/tests/qt_helpers.py +++ /dev/null @@ -1,287 +0,0 @@ -import time -from pathlib import Path - -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction -from PySide6.QtTest import QTest -from PySide6.QtWidgets import ( - QApplication, - QWidget, - QDialog, - QFileDialog, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QAbstractButton, - QListWidget, -) - -# ---------- robust widget finders ---------- - - -def _visible_widgets(): - for w in QApplication.topLevelWidgets(): - if w.isVisible(): - yield w - for c in w.findChildren(QWidget): - if c.isWindow() and c.isVisible(): - yield c - - -def wait_for_widget(cls=None, predicate=lambda w: True, timeout_ms: int = 15000): - deadline = time.time() + timeout_ms / 1000.0 - while time.time() < deadline: - for w in _visible_widgets(): - if (cls is None or isinstance(w, cls)) and predicate(w): - return w - QTest.qWait(25) - raise TimeoutError(f"Timed out waiting for {cls} matching predicate") - - -# ---------- generic ui helpers ---------- - - -def click_button_by_text(container: QWidget, contains: str) -> bool: - """Click any QAbstractButton whose label contains the substring.""" - target = contains.lower() - for btn in container.findChildren(QAbstractButton): - text = (btn.text() or "").lower() - if target in text: - from PySide6.QtTest import QTest - - if not btn.isEnabled(): - QTest.qWait(50) # give UI a tick to enable - QTest.mouseClick(btn, Qt.LeftButton) - return True - return False - - -def _first_line_edit(dlg: QDialog) -> QLineEdit | None: - edits = dlg.findChildren(QLineEdit) - return edits[0] if edits else None - - -def fill_first_line_edit_and_accept(dlg: QDialog, text: str | None): - le = _first_line_edit(dlg) - assert le is not None, "Expected a QLineEdit in the dialog" - if text is not None: - le.clear() - QTest.keyClicks(le, text) - # Prefer 'OK'; fallback to Return - ok = None - for btn in dlg.findChildren(QPushButton): - t = btn.text().lower().lstrip("&") - if t == "ok" or btn.isDefault(): - ok = btn - break - if ok: - QTest.mouseClick(ok, Qt.LeftButton) - else: - QTest.keyClick(le, Qt.Key_Return) - - -def accept_all_message_boxes(limit: int = 5) -> bool: - """ - Accept every visible QMessageBox, preferring Yes/Accept/Ok. - Returns True if at least one box was accepted. - """ - accepted_any = False - for _ in range(limit): - accepted_this_round = False - for w in _visible_widgets(): - if isinstance(w, QMessageBox) and w.isVisible(): - # Prefer "Yes", then any Accept/Apply role, then Ok, then default/first. - btn = ( - w.button(QMessageBox.Yes) - or next( - ( - b - for b in w.buttons() - if w.buttonRole(b) - in ( - QMessageBox.YesRole, - QMessageBox.AcceptRole, - QMessageBox.ApplyRole, - ) - ), - None, - ) - or w.button(QMessageBox.Ok) - or w.defaultButton() - or (w.buttons()[0] if w.buttons() else None) - ) - if btn: - QTest.mouseClick(btn, Qt.LeftButton) - accepted_this_round = True - accepted_any = True - if not accepted_this_round: - break - QTest.qWait(30) # give the next box a tick to appear - return accepted_any - - -def trigger_menu_action(win, text_contains: str) -> QAction: - for act in win.findChildren(QAction): - if text_contains in act.text(): - act.trigger() - return act - raise AssertionError(f"Action containing '{text_contains}' not found") - - -def find_line_edit_by_placeholder(container: QWidget, needle: str) -> QLineEdit | None: - n = needle.lower() - for le in container.findChildren(QLineEdit): - if n in (le.placeholderText() or "").lower(): - return le - return None - - -class AutoResponder: - def __init__(self): - self._seen: set[int] = set() - self._timer = QTimer() - self._timer.setInterval(50) - self._timer.timeout.connect(self._tick) - - def start(self): - self._timer.start() - - def stop(self): - self._timer.stop() - - def prehandle_key_prompts_if_present(self): - for w in _visible_widgets(): - if isinstance(w, QDialog) and ( - _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w) - ): - fill_first_line_edit_and_accept(w, "ci-secret-key") - - def _tick(self): - if accept_all_message_boxes(limit=3): - return - - for w in _visible_widgets(): - if not isinstance(w, QDialog) or not w.isVisible(): - continue - - wid = id(w) - # Handle first-run / unlock / save-name prompts - if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w): - fill_first_line_edit_and_accept(w, "ci-secret-key") - self._seen.add(wid) - continue - - if _looks_like_save_version_dialog(w): - fill_first_line_edit_and_accept(w, None) - self._seen.add(wid) - continue - - if _is_history_dialog(w): - # Don't mark as seen until we've actually clicked the button. - if _click_revert_in_history(w): - accept_all_message_boxes(limit=5) - self._seen.add(wid) - continue - - -# ---------- dialog classifiers ---------- - - -def _looks_like_set_key_dialog(dlg: QDialog) -> bool: - labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() - title = (dlg.windowTitle() or "").lower() - has_line = bool(dlg.findChildren(QLineEdit)) - return has_line and ( - "set an encryption key" in title - or "create a strong passphrase" in labels - or "encrypts your data" in labels - ) - - -def _looks_like_unlock_dialog(dlg: QDialog) -> bool: - labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() - title = (dlg.windowTitle() or "").lower() - has_line = bool(dlg.findChildren(QLineEdit)) - return has_line and ("unlock" in labels or "unlock" in title) and "key" in labels - - -# ---------- version prompt ---------- -def _looks_like_save_version_dialog(dlg: QDialog) -> bool: - labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() - title = (dlg.windowTitle() or "").lower() - has_line = bool(dlg.findChildren(QLineEdit)) - return has_line and ( - "enter a name" in labels or "name for this version" in labels or "save" in title - ) - - -# ---------- QFileDialog driver ---------- - - -def drive_qfiledialog_save(path: Path, name_filter: str | None = None): - dlg = wait_for_widget(QFileDialog, timeout_ms=20000) - if name_filter: - try: - dlg.selectNameFilter(name_filter) - except Exception: - pass - - # Prefer typing in the filename edit so Save enables on all styles - filename_edit = None - for le in dlg.findChildren(QLineEdit): - if le.echoMode() == QLineEdit.Normal: - filename_edit = le - break - - if filename_edit is not None: - filename_edit.clear() - QTest.keyClicks(filename_edit, str(path)) - # Return usually triggers Save in non-native dialogs - QTest.keyClick(filename_edit, Qt.Key_Return) - else: - dlg.selectFile(str(path)) - QTimer.singleShot(0, dlg.accept) - - # Some themes still need an explicit Save click - _ = click_button_by_text(dlg, "save") - - -def _is_history_dialog(dlg: QDialog) -> bool: - if not isinstance(dlg, QDialog) or not dlg.isVisible(): - return False - title = (dlg.windowTitle() or "").lower() - if "history" in title: - return True - return bool(dlg.findChildren(QListWidget)) - - -def _click_revert_in_history(dlg: QDialog) -> bool: - """ - Returns True if we successfully clicked an enabled 'Revert' button. - Ensures a row is actually clicked first so the button enables. - """ - lists = dlg.findChildren(QListWidget) - if not lists: - return False - versions = max(lists, key=lambda lw: lw.count()) - if versions.count() < 2: - return False - - # Click the older row (index 1); real click so the dialog enables the button. - from PySide6.QtTest import QTest - from PySide6.QtCore import Qt - - rect = versions.visualItemRect(versions.item(1)) - QTest.mouseClick(versions.viewport(), Qt.LeftButton, pos=rect.center()) - QTest.qWait(60) - - # Find any enabled button that looks like "revert" - for btn in dlg.findChildren(QAbstractButton): - meta = " ".join( - [(btn.text() or ""), (btn.toolTip() or ""), (btn.objectName() or "")] - ).lower() - if "revert" in meta and btn.isEnabled(): - QTest.mouseClick(btn, Qt.LeftButton) - return True - return False diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py new file mode 100644 index 0000000..8d773e9 --- /dev/null +++ b/tests/test_bug_report_dialog.py @@ -0,0 +1,324 @@ +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"] diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py new file mode 100644 index 0000000..145e156 --- /dev/null +++ b/tests/test_code_highlighter.py @@ -0,0 +1,398 @@ +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 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\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 = "" + + 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 = "" + + 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) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..7896c98 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,604 @@ +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(): + return dt.date.today().isoformat() + + +def _yesterday(): + return (dt.date.today() - dt.timedelta(days=1)).isoformat() + + +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" + + +def test_connect_integrity_and_schema(fresh_db): + d = _today() + fresh_db.save_new_version(d, _entry("hello world"), "initial") + vlist = fresh_db.list_versions(d) + assert vlist + v = fresh_db.get_version(version_id=vlist[0]["id"]) + assert v and "created_at" in v + + +def test_save_and_get_entry_versions(fresh_db): + d = _today() + fresh_db.save_new_version(d, _entry("hello world"), "initial") + txt = fresh_db.get_entry(d) + assert "hello world" in txt + + fresh_db.save_new_version(d, _entry("hello again"), "second") + versions = fresh_db.list_versions(d) + assert len(versions) >= 2 + assert any(v["is_current"] for v in versions) + + first = sorted(versions, key=lambda v: v["version_no"])[0] + fresh_db.revert_to_version(d, version_id=first["id"]) + txt2 = fresh_db.get_entry(d) + assert "hello world" in txt2 and "again" not in txt2 + + +def test_dates_with_content_and_search(fresh_db): + fresh_db.save_new_version(_today(), _entry("alpha bravo"), "t1") + fresh_db.save_new_version(_yesterday(), _entry("bravo charlie"), "t2") + fresh_db.save_new_version(_tomorrow(), _entry("delta alpha"), "t3") + + dates = set(fresh_db.dates_with_content()) + assert _today() in dates and _yesterday() in dates and _tomorrow() in dates + + hits = list(fresh_db.search_entries("alpha")) + assert any(d == _today() for d, _ in hits) + assert any(d == _tomorrow() for d, _ in hits) + + +def test_get_all_entries_and_export(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}") + entries = fresh_db.get_all_entries() + assert entries and all(len(t) == 2 for t in entries) + + json_path = tmp_path / "export.json" + fresh_db.export_json(entries, str(json_path)) + assert json_path.exists() and json.load(open(json_path)) is not None + + csv_path = tmp_path / "export.csv" + fresh_db.export_csv(entries, str(csv_path)) + assert csv_path.exists() and list(csv.reader(open(csv_path))) + + md_path = tmp_path / "export.md" + fresh_db.export_markdown(entries, str(md_path)) + md_text = md_path.read_text() + assert md_path.exists() and entries[0][0] in md_text + + html_path = tmp_path / "export.html" + fresh_db.export_html(entries, str(html_path), title="My Notebook") + assert html_path.exists() and " 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 bold and italic text with
divs
" + 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 + +

HTML paragraph

+""" + 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 "

" 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 diff --git a/tests/test_db_migrations_and_versions.py b/tests/test_db_migrations_and_versions.py deleted file mode 100644 index 8fd1166..0000000 --- a/tests/test_db_migrations_and_versions.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import pytest - -from bouquin.db import DBManager, DBConfig - -# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs -from sqlcipher3 import dbapi2 as sqlite - - -def connect_raw_sqlcipher(db_path: Path, key: str): - conn = sqlite.connect(str(db_path)) - conn.row_factory = sqlite.Row - cur = conn.cursor() - cur.execute(f"PRAGMA key = '{key}';") - cur.execute("PRAGMA foreign_keys = ON;") - cur.execute("PRAGMA journal_mode = WAL;").fetchone() - return conn - - -def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path): - # Prepare a "legacy" DB that has only entries(date, content) and no pages/versions - db_path = cfg.path - conn = connect_raw_sqlcipher(db_path, cfg.key) - cur = conn.cursor() - cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);") - cur.execute( - "INSERT INTO entries(date, content) VALUES(?, ?);", - ("2025-01-02", "

Hello

"), - ) - conn.commit() - conn.close() - - # Now use the real DBManager, which will run _ensure_schema and migrate - mgr = DBManager(cfg) - assert mgr.connect() is True - - # After migration, legacy table should be gone and content reachable via get_entry - text = mgr.get_entry("2025-01-02") - assert "Hello" in text - - cur = mgr.conn.cursor() - # entries table should be dropped - with pytest.raises(sqlite.OperationalError): - cur.execute("SELECT count(*) FROM entries;").fetchone() - - # pages & versions exist and head points to v1 - rows = cur.execute( - "SELECT current_version_id FROM pages WHERE date='2025-01-02'" - ).fetchone() - assert rows is not None and rows["current_version_id"] is not None - vers = mgr.list_versions("2025-01-02") - assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1 - - -def test_save_new_version_requires_connection_raises(cfg: DBConfig): - mgr = DBManager(cfg) - with pytest.raises(RuntimeError): - mgr.save_new_version("2025-01-03", "

x

") - - -def _bootstrap_db(cfg: DBConfig) -> DBManager: - mgr = DBManager(cfg) - assert mgr.connect() is True - return mgr - - -def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig): - mgr = _bootstrap_db(cfg) - # Create two versions for the same date - ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "

v1

", note="init") - ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "

v2

", note="edit") - assert ver1_no == 1 and ver2_no == 2 - - # Revert using version_id - mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id) - cur = mgr.conn.cursor() - head2 = cur.execute( - "SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",) - ).fetchone()[0] - assert head2 == ver2_id - - # Error: version_id belongs to a different date - other_id, _ = mgr.save_new_version("2025-01-05", "

other

") - with pytest.raises(ValueError): - mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id) - - -def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path): - mgr = _bootstrap_db(cfg) - # Seed a couple of entries - mgr.save_new_version("2025-01-06", "

A

") - mgr.save_new_version("2025-01-07", "

B

") - - # Prepare output files - out = tmp_path - exts = [ - ".json", - ".csv", - ".txt", - ".html", - ".sql", - ] # exclude .md due to different signature - for ext in exts: - path = out / f"export{ext}" - mgr.export_by_extension(str(path)) - assert path.exists() and path.stat().st_size > 0 - - # Markdown export uses a different signature (entries + path) - entries = mgr.get_all_entries() - md_path = out / "export.md" - mgr.export_markdown(entries, str(md_path)) - assert md_path.exists() and md_path.stat().st_size > 0 - - # Run VACUUM path - mgr.compact() # should not raise diff --git a/tests/test_db_unit.py b/tests/test_db_unit.py deleted file mode 100644 index 8c80160..0000000 --- a/tests/test_db_unit.py +++ /dev/null @@ -1,137 +0,0 @@ -import bouquin.db as dbmod -from bouquin.db import DBConfig, DBManager - - -class FakeCursor: - def __init__(self, rows=None): - self._rows = rows or [] - self.executed = [] - - def execute(self, sql, params=None): - self.executed.append((sql, tuple(params) if params else None)) - return self - - def fetchall(self): - return list(self._rows) - - def fetchone(self): - return self._rows[0] if self._rows else None - - -class FakeConn: - def __init__(self, rows=None): - self._rows = rows or [] - self.closed = False - self.cursors = [] - self.row_factory = None - - def cursor(self): - c = FakeCursor(rows=self._rows) - self.cursors.append(c) - return c - - def close(self): - self.closed = True - - def commit(self): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - pass - - -def test_integrity_ok_ok(monkeypatch, tmp_path): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) - mgr.conn = FakeConn(rows=[]) - assert mgr._integrity_ok() is None - - -def test_integrity_ok_raises(monkeypatch, tmp_path): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) - mgr.conn = FakeConn(rows=[("oops",), (None,)]) - try: - mgr._integrity_ok() - except Exception as e: - assert isinstance(e, dbmod.sqlite.IntegrityError) - - -def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path): - # Use a non-empty key to avoid SQLCipher complaining before our patch runs - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) - # Make the integrity check raise so connect() takes the failure path - monkeypatch.setattr( - DBManager, - "_integrity_ok", - lambda self: (_ for _ in ()).throw(RuntimeError("bad")), - ) - ok = mgr.connect() - assert ok is False - assert mgr.conn is None - - -def test_rekey_not_connected_raises(tmp_path): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old")) - mgr.conn = None - import pytest - - with pytest.raises(RuntimeError): - mgr.rekey("new") - - -def test_rekey_reopen_failure(monkeypatch, tmp_path): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old")) - mgr.conn = FakeConn(rows=[(None,)]) - monkeypatch.setattr(DBManager, "connect", lambda self: False) - import pytest - - with pytest.raises(Exception): - mgr.rekey("new") - - -def test_export_by_extension_and_unknown(tmp_path): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) - entries = [("2025-01-01", "Hi")] - # Test each exporter writes the file - p = tmp_path / "out.json" - mgr.export_json(entries, str(p)) - assert p.exists() and p.stat().st_size > 0 - p = tmp_path / "out.csv" - mgr.export_csv(entries, str(p)) - assert p.exists() - p = tmp_path / "out.txt" - mgr.export_txt(entries, str(p)) - assert p.exists() - p = tmp_path / "out.html" - mgr.export_html(entries, str(p)) - assert p.exists() - p = tmp_path / "out.md" - mgr.export_markdown(entries, str(p)) - assert p.exists() - # Router - import types - - mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) - for ext in [".json", ".csv", ".txt", ".html", ".md"]: - path = tmp_path / f"route{ext}" - mgr.export_by_extension(str(path)) - assert path.exists() - import pytest - - with pytest.raises(ValueError): - mgr.export_by_extension(str(tmp_path / "x.zzz")) - - -def test_compact_error_prints(monkeypatch, tmp_path, capsys): - mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) - - class BadConn: - def cursor(self): - raise RuntimeError("no") - - mgr.conn = BadConn() - mgr.compact() - out = capsys.readouterr().out - assert "Error:" in out diff --git a/tests/test_e2e_actions.py b/tests/test_e2e_actions.py deleted file mode 100644 index 55f7ae5..0000000 --- a/tests/test_e2e_actions.py +++ /dev/null @@ -1,55 +0,0 @@ -from PySide6.QtCore import QUrl, QObject, Slot -from PySide6.QtGui import QDesktopServices -from PySide6.QtTest import QTest -from tests.qt_helpers import trigger_menu_action - - -def test_launch_write_save_and_navigate(open_window, qtbot, today_iso): - win = open_window - win.editor.setPlainText("Hello Bouquin") - qtbot.waitUntil(lambda: win.editor.toPlainText() == "Hello Bouquin", timeout=15000) - - trigger_menu_action(win, "Save a version") # AutoResponder clicks OK - - versions = win.db.list_versions(today_iso) - assert versions and versions[0]["is_current"] == 1 - - selected = win.calendar.selectedDate() - trigger_menu_action(win, "Next Day") - qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected.addDays(1)) - trigger_menu_action(win, "Previous Day") - qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected) - win.calendar.setSelectedDate(selected.addDays(3)) - trigger_menu_action(win, "Today") - qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected) - - -def test_help_menu_opens_urls(open_window, qtbot): - opened: list[str] = [] - - class UrlCatcher(QObject): - @Slot(QUrl) - def handle(self, url: QUrl): - opened.append(url.toString()) - - catcher = UrlCatcher() - # Qt6/PySide6: setUrlHandler(scheme, receiver, methodName) - QDesktopServices.setUrlHandler("https", catcher, "handle") - QDesktopServices.setUrlHandler("http", catcher, "handle") - try: - win = open_window - trigger_menu_action(win, "Documentation") - trigger_menu_action(win, "Report a bug") - QTest.qWait(150) - assert len(opened) >= 2 - finally: - QDesktopServices.unsetUrlHandler("https") - QDesktopServices.unsetUrlHandler("http") - - -def test_idle_lock_and_unlock(open_window, qtbot): - win = open_window - win._enter_lock() - assert getattr(win, "_locked", False) is True - win._on_unlock_clicked() # AutoResponder types 'ci-secret-key' - qtbot.waitUntil(lambda: getattr(win, "_locked", True) is False, timeout=15000) diff --git a/tests/test_editor.py b/tests/test_editor.py deleted file mode 100644 index e0951b8..0000000 --- a/tests/test_editor.py +++ /dev/null @@ -1,339 +0,0 @@ -from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl -from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication - -from bouquin.editor import Editor -from bouquin.theme import ThemeManager, ThemeConfig, Theme - -import re - - -def _mk_editor() -> Editor: - # pytest-qt ensures a QApplication exists - app = QApplication.instance() - tm = ThemeManager(app, ThemeConfig()) - return Editor(tm) - - -def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None: - c = editor.textCursor() - c.movePosition(QTextCursor.Start) - while True: - c2 = QTextCursor(c) - c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - if c2.position() == c.position(): - break - fmt = c2.charFormat() - if fmt.isImageFormat(): - editor.setTextCursor(c2) - return QTextImageFormat(fmt) - c.movePosition(QTextCursor.Right) - return None - - -def _fmt_at(editor: Editor, pos: int): - c = editor.textCursor() - c.setPosition(pos) - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - return c.charFormat() - - -def test_space_breaks_link_anchor_and_styling(qtbot): - e = _mk_editor() - e.resize(600, 300) - e.show() - qtbot.waitExposed(e) - - # Type a URL, which should be linkified (anchor + underline + blue) - url = "https://mig5.net" - QTest.keyClicks(e, url) - qtbot.waitUntil(lambda: e.toPlainText() == url) - - # Sanity: characters within the URL are anchors - for i in range(len(url)): - assert _fmt_at(e, i).isAnchor() - - # Hit Space – Editor.keyPressEvent() should call _break_anchor_for_next_char() - QTest.keyClick(e, Qt.Key_Space) - - # Type some normal text; it must not inherit the link formatting - tail = "this is a test" - QTest.keyClicks(e, tail) - qtbot.waitUntil(lambda: e.toPlainText().endswith(tail)) - - txt = e.toPlainText() - # Find where our 'tail' starts - start = txt.index(tail) - end = start + len(tail) - - # None of the trailing characters should be part of an anchor or visually underlined - for i in range(start, end): - fmt = _fmt_at(e, i) - assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor" - assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined" - - # Optional: ensure the HTML only wraps the URL in , not the trailing text - html = e.document().toHtml() - assert re.search( - r']*href="https?://mig5\.net"[^>]*>(?:]*>)?https?://mig5\.net(?:)?\s+this is a test', - html, - re.S, - ), html - assert "this is a test" not in html - - -def test_embed_qimage_saved_as_data_url(qtbot): - e = _mk_editor() - e.resize(600, 400) - qtbot.addWidget(e) - e.show() - qtbot.waitExposed(e) - - img = QImage(60, 40, QImage.Format_ARGB32) - img.fill(0xFF336699) - e._insert_qimage_at_cursor(img, autoscale=False) - - html = e.to_html_with_embedded_images() - assert "data:image/png;base64," in html - - -def test_insert_images_autoscale_and_fit(qtbot, tmp_path): - # Create a very wide image so autoscale triggers - big = QImage(2000, 800, QImage.Format_ARGB32) - big.fill(0xFF00FF00) - big_path = tmp_path / "big.png" - big.save(str(big_path)) - - e = _mk_editor() - e.resize(420, 300) # known viewport width - qtbot.addWidget(e) - e.show() - qtbot.waitExposed(e) - - e.insert_images([str(big_path)], autoscale=True) - - # Cursor lands after the image + a blank block; helper will select the image char - fmt = _move_cursor_to_first_image(e) - assert fmt is not None - - # After autoscale, width should be <= ~92% of viewport - max_w = int(e.viewport().width() * 0.92) - assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding - - # Now exercise "fit to editor width" - e._fit_image_to_editor_width() - _tc, fmt2, _orig = e._image_info_at_cursor() - assert fmt2 is not None - assert abs(fmt2.width() - max_w) <= 1 - - -def test_linkify_trims_trailing_punctuation(qtbot): - e = _mk_editor() - qtbot.addWidget(e) - e.show() - qtbot.waitExposed(e) - - e.setPlainText("See (https://example.com).") - # Wait until linkification runs (connected to textChanged) - qtbot.waitUntil(lambda: 'href="' in e.document().toHtml()) - - html = e.document().toHtml() - # Anchor should *not* include the closing ')' - assert 'href="https://example.com"' in html - assert 'href="https://example.com)."' not in html - - -def test_code_block_enter_exits_on_empty_line(qtbot): - - e = _mk_editor() - qtbot.addWidget(e) - e.show() - qtbot.waitExposed(e) - - e.setPlainText("code") - c = e.textCursor() - c.select(QTextCursor.BlockUnderCursor) - e.setTextCursor(c) - e.apply_code() - - # Put caret at end of the code block, then Enter to create an empty line *inside* the frame - c = e.textCursor() - c.movePosition(QTextCursor.EndOfBlock) - e.setTextCursor(c) - - QTest.keyClick(e, Qt.Key_Return) - # Ensure we are on an empty block *inside* the code frame - qtbot.waitUntil( - lambda: e._nearest_code_frame(e.textCursor(), tolerant=False) is not None - and e.textCursor().block().length() == 1 - ) - - # Second Enter should jump *out* of the frame - QTest.keyClick(e, Qt.Key_Return) - - -class DummyMenu: - def __init__(self): - self.seps = 0 - self.subs = [] - self.exec_called = False - - def addSeparator(self): - self.seps += 1 - - def addMenu(self, title): - m = DummyMenu() - self.subs.append((title, m)) - return m - - def addAction(self, *a, **k): - pass - - def exec(self, *a, **k): - self.exec_called = True - - -def _themes(): - app = QApplication.instance() - return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - - -def test_context_menu_adds_image_actions(monkeypatch, qtbot): - e = Editor(_themes()) - qtbot.addWidget(e) - # Fake an image at cursor - qi = QImage(10, 10, QImage.Format_ARGB32) - qi.fill(0xFF00FF00) - imgfmt = QTextImageFormat() - imgfmt.setName("x") - imgfmt.setWidth(10) - imgfmt.setHeight(10) - tc = e.textCursor() - monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi)) - - dummy = DummyMenu() - monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy) - - class Evt: - def globalPos(self): - return QPoint(0, 0) - - e.contextMenuEvent(Evt()) - assert dummy.exec_called - assert dummy.seps == 1 - assert any(t == "Image size" for t, _ in dummy.subs) - - -def test_insert_from_mime_image_and_urls(tmp_path, qtbot): - e = Editor(_themes()) - qtbot.addWidget(e) - # Build a mime with an image - mime = QMimeData() - img = QImage(6, 6, QImage.Format_ARGB32) - img.fill(0xFF0000FF) - mime.setImageData(img) - e.insertFromMimeData(mime) - html = e.document().toHtml() - assert "' - - md = QMimeData() - md.setHtml(html) - editor.insertFromMimeData(md) - - # HTML export with embedded images should contain a data: URL - h = editor.to_html_with_embedded_images() - assert "data:image/png;base64," in h - - -def test_toggle_checkboxes_selection(editor): - editor.clear() - editor.setPlainText("item 1\nitem 2") - # Select both lines - c = editor.textCursor() - c.movePosition(QTextCursor.Start) - c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - editor.setTextCursor(c) - # Toggle on -> inserts ☐ - editor.toggle_checkboxes() - assert editor.toPlainText().startswith("☐ ") - # Toggle again -> remove ☐ - editor.toggle_checkboxes() - assert not editor.toPlainText().startswith("☐ ") - - -def test_heading_then_enter_reverts_to_normal(editor): - editor.clear() - editor.setPlainText("A heading") - # Apply H2 via apply_heading(size=18) - editor.apply_heading(18) - c = editor.textCursor() - c.movePosition(QTextCursor.End) - editor.setTextCursor(c) - # Press Enter -> new block should be Normal (not bold/large) - QTest.keyClick(editor, Qt.Key_Return) - # The new block exists - txt = editor.toPlainText() - assert "\n" in txt diff --git a/tests/test_editor_images_text_states.py b/tests/test_editor_images_text_states.py deleted file mode 100644 index 8cb81d9..0000000 --- a/tests/test_editor_images_text_states.py +++ /dev/null @@ -1,75 +0,0 @@ -from PySide6.QtCore import QUrl -from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor -from bouquin.theme import ThemeManager -from bouquin.editor import Editor - - -def _mk_editor(qapp, cfg): - themes = ThemeManager(qapp, cfg) - ed = Editor(themes) - ed.resize(400, 300) - return ed - - -def test_image_scale_and_reset(qapp, cfg): - ed = _mk_editor(qapp, cfg) - - # Register an image resource and insert it at the cursor - img = QImage(20, 10, QImage.Format_ARGB32) - img.fill(QColor(200, 0, 0)) - url = QUrl("test://img") - from PySide6.QtGui import QTextDocument - - ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img) - - fmt = QTextImageFormat() - fmt.setName(url.toString()) - # No explicit width -> code should use original width - tc = ed.textCursor() - tc.insertImage(fmt) - - # Place cursor at start (on the image) and scale - tc = ed.textCursor() - tc.movePosition(QTextCursor.Start) - ed.setTextCursor(tc) - ed._scale_image_at_cursor(1.5) # increases width - ed._reset_image_size() # restores to original width - - # Ensure resulting HTML contains an tag - html = ed.toHtml() - assert " fallback branch inside _apply_image_size - fmt = QTextImageFormat() - fmt.setName("") # no resource available - tc = ed.textCursor() - # Insert a single character to have a valid cursor - tc.insertText("x") - tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) - ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise - - -def test_to_html_with_embedded_images_and_link_tint(qapp, cfg): - ed = _mk_editor(qapp, cfg) - - # Insert an anchor + image and ensure HTML embedding + retint pass runs - img = QImage(8, 8, QImage.Format_ARGB32) - img.fill(QColor(0, 200, 0)) - url = QUrl("test://img2") - from PySide6.QtGui import QTextDocument - - ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img) - - # Compose HTML with a link and an image referencing our resource - ed.setHtml( - f'

link

' - ) - - html = ed.to_html_with_embedded_images() - # Embedded data URL should appear for the image - assert "data:image" in html - # The link should still be present (retinted internally) without crashing - assert "example.com" in html diff --git a/tests/test_editor_more.py b/tests/test_editor_more.py deleted file mode 100644 index fd015a9..0000000 --- a/tests/test_editor_more.py +++ /dev/null @@ -1,136 +0,0 @@ -from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot -from PySide6.QtGui import QImage, QMouseEvent, QTextCursor -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication - -from bouquin.editor import Editor -from bouquin.theme import ThemeManager, ThemeConfig - - -def _mk_editor() -> Editor: - app = QApplication.instance() - tm = ThemeManager(app, ThemeConfig()) - e = Editor(tm) - e.resize(700, 400) - e.show() - return e - - -def _point_for_char(e: Editor, pos: int): - c = e.textCursor() - c.setPosition(pos) - r = e.cursorRect(c) - return r.center() - - -def test_trim_url_and_linkify_and_ctrl_mouse(qtbot): - e = _mk_editor() - qtbot.addWidget(e) - assert e._trim_url_end("https://ex.com)") == "https://ex.com" - assert e._trim_url_end("www.mysite.org]") == "www.mysite.org" - - url = "https://example.org/path" - QTest.keyClicks(e, url) - qtbot.waitUntil(lambda: url in e.toPlainText()) - - p = _point_for_char(e, 0) - move = QMouseEvent( - QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier - ) - e.mouseMoveEvent(move) - assert e.viewport().cursor().shape() == Qt.PointingHandCursor - - opened = {} - - class Catcher(QObject): - @Slot(QUrl) - def handle(self, u: QUrl): - opened["u"] = u.toString() - - from PySide6.QtGui import QDesktopServices - - catcher = Catcher() - QDesktopServices.setUrlHandler("https", catcher, "handle") - try: - rel = QMouseEvent( - QEvent.MouseButtonRelease, - p, - Qt.LeftButton, - Qt.LeftButton, - Qt.ControlModifier, - ) - e.mouseReleaseEvent(rel) - got_signal = [] - e.linkActivated.connect(lambda href: got_signal.append(href)) - e.mouseReleaseEvent(rel) - assert opened or got_signal - finally: - QDesktopServices.unsetUrlHandler("https") - - -def test_insert_images_and_image_helpers(qtbot, tmp_path): - e = _mk_editor() - qtbot.addWidget(e) - - # No image under cursor yet (412 guard) - tc, fmt, orig = e._image_info_at_cursor() - assert tc is None and fmt is None and orig is None - - # Insert a real image file (574–584 path) - img_path = tmp_path / "tiny.png" - img = QImage(4, 4, QImage.Format_ARGB32) - img.fill(0xFF336699) - assert img.save(str(img_path), "PNG") - e.insert_images([str(img_path)], autoscale=False) - assert " new line with fresh checkbox (680–684) - c = e.textCursor() - c.movePosition(QTextCursor.End) - e.setTextCursor(c) - QTest.keyClick(e, Qt.Key_Return) - lines = e.toPlainText().splitlines() - assert len(lines) >= 2 and lines[1].startswith("☐ ") - - -def test_heading_and_lists_toggle_remove(qtbot): - e = _mk_editor() - qtbot.addWidget(e) - e.setPlainText("para") - - # "Normal" path is size=0 (904…) - e.apply_heading(0) - - # bullets twice -> second call removes (945–946) - e.toggle_bullets() - e.toggle_bullets() - # numbers twice -> second call removes (955–956) - e.toggle_numbers() - e.toggle_numbers() diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py deleted file mode 100644 index b6486be..0000000 --- a/tests/test_entrypoints.py +++ /dev/null @@ -1,69 +0,0 @@ -import importlib - - -def test___main___exports_main(): - entry_mod = importlib.import_module("bouquin.__main__") - main_mod = importlib.import_module("bouquin.main") - assert entry_mod.main is main_mod.main - - -def test_main_entry_initializes_qt(monkeypatch): - main_mod = importlib.import_module("bouquin.main") - - # Fakes to avoid real Qt event loop - class FakeApp: - def __init__(self, argv): - self.argv = argv - self.name = None - self.org = None - - def setApplicationName(self, n): - self.name = n - - def setOrganizationName(self, n): - self.org = n - - def exec(self): - return 0 - - class FakeWin: - def __init__(self, themes=None): - self.themes = themes - self.shown = False - - def show(self): - self.shown = True - - class FakeThemes: - def __init__(self, app, cfg): - self._applied = None - self.app = app - self.cfg = cfg - - def apply(self, t): - self._applied = t - - class FakeSettings: - def __init__(self): - self._map = {"ui/theme": "dark"} - - def value(self, k, default=None, type=None): - return self._map.get(k, default) - - def fake_get_settings(): - return FakeSettings() - - monkeypatch.setattr(main_mod, "QApplication", FakeApp) - monkeypatch.setattr(main_mod, "MainWindow", FakeWin) - monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes) - monkeypatch.setattr(main_mod, "get_settings", fake_get_settings) - - exits = {} - - def fake_exit(code): - exits["code"] = code - - monkeypatch.setattr(main_mod.sys, "exit", fake_exit) - - main_mod.main() - assert exits.get("code", None) == 0 diff --git a/tests/test_export_backup.py b/tests/test_export_backup.py deleted file mode 100644 index ec000e8..0000000 --- a/tests/test_export_backup.py +++ /dev/null @@ -1,112 +0,0 @@ -import csv, json, sqlite3 - -import pytest - -from tests.qt_helpers import trigger_menu_action, accept_all_message_boxes - -# Export filters used by the app (format is chosen by this name filter, not by extension) -EXPORT_FILTERS = { - ".txt": "Text (*.txt)", - ".json": "JSON (*.json)", - ".csv": "CSV (*.csv)", - ".html": "HTML (*.html)", - ".sql": "SQL (*.sql)", # app writes a SQLite DB here -} -BACKUP_FILTER = "SQLCipher (*.db)" - - -def _write_sample_entries(win, qtbot): - win.editor.setPlainText("alpha bold") - win._save_current(explicit=True) - d = win.calendar.selectedDate().addDays(1) - win.calendar.setSelectedDate(d) - win.editor.setPlainText("beta text") - win._save_current(explicit=True) - - -@pytest.mark.parametrize( - "ext,verifier", - [ - (".txt", lambda p: p.read_text(encoding="utf-8").strip()), - (".json", lambda p: json.loads(p.read_text(encoding="utf-8"))), - (".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))), - (".html", lambda p: p.read_text(encoding="utf-8")), - (".sql", lambda p: p), - ], -) -def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch): - win = open_window - _write_sample_entries(win, qtbot) - - out = tmp_path / f"export_test{ext}" - - # 1) Short-circuit the file dialog so it returns our path + the filter we want. - from PySide6.QtWidgets import QFileDialog - - def fake_getSaveFileName(*args, **kwargs): - return (str(out), EXPORT_FILTERS[ext]) - - monkeypatch.setattr( - QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName) - ) - - # 2) Kick off the export - trigger_menu_action(win, "Export") - - # 3) Click through the "unencrypted export" warning - accept_all_message_boxes() - - # 4) Wait for the file to appear (export happens synchronously after the stub) - qtbot.waitUntil(out.exists, timeout=5000) - - # 5) Dismiss the "Export complete" info box so it can't block later tests - accept_all_message_boxes() - - # 6) Assert as before - val = verifier(out) - if ext == ".json": - assert isinstance(val, list) and all( - "date" in d and "content" in d for d in val - ) - elif ext == ".csv": - flat = [cell for row in val for cell in row] - assert any("alpha" in c for c in flat) and any("beta" in c for c in flat) - elif ext == ".html": - lower = val.lower() - assert " pos1 - act_next = trigger_menu_action(win, "Find Next") - assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch + fb.find_prev() + pos3 = editor.textCursor().position() + assert pos3 <= pos2 - act_prev = trigger_menu_action(win, "Find Previous") - assert ( - act_prev.shortcut().matches(QKeySequence.FindPrevious) - == QKeySequence.ExactMatch - ) - - # "Find on page" should open the bar and focus the input - act_find.trigger() - qtbot.waitUntil(lambda: win.findBar.isVisible()) - qtbot.waitUntil(lambda: win.findBar.edit.hasFocus()) + fb.case.setChecked(True) + fb.refresh() + fb.hide_bar() -def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot): - win = open_window +def test_show_bar_seeds_selection(qtbot, editor): - # Mixed-case content with three matches - text = "alpha … ALPHA … alpha" - win.editor.setPlainText(text) - qtbot.waitUntil(lambda: win.editor.toPlainText() == text) + editor.from_markdown("alpha beta") + c = editor.textCursor() + c.movePosition(QTextCursor.Start) + c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor) + editor.setTextCursor(c) - # Open the find bar from the menu - trigger_menu_action(win, "Find on page").trigger() - qtbot.waitUntil(lambda: win.findBar.isVisible()) - win.findBar.edit.clear() - QTest.keyClicks(win.findBar.edit, "alpha") + fb = FindBar(editor, parent=editor) + qtbot.addWidget(fb) + fb.show_bar() + assert fb.edit.text().lower() == "alpha" + fb.hide_bar() - # 1) First hit (case-insensitive default) - QTest.keyClick(win.findBar.edit, Qt.Key_Return) - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s0, e0, sel0 = _cursor_info(win.editor) - assert sel0.lower() == "alpha" - # 2) Next → uppercase ALPHA (case-insensitive) - trigger_menu_action(win, "Find Next").trigger() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s1, e1, sel1 = _cursor_info(win.editor) - assert sel1.upper() == "ALPHA" +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() - # 3) Next → the *other* lowercase "alpha" - trigger_menu_action(win, "Find Next").trigger() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s2, e2, sel2 = _cursor_info(win.editor) - assert sel2.lower() == "alpha" - # Ensure we didn't wrap back to the very first "alpha" - assert s2 != s0 - # 4) Case-sensitive: skip ALPHA and only hit lowercase - win.findBar.case.setChecked(True) - # Put the caret at start to make the next search deterministic - tc = win.editor.textCursor() - tc.setPosition(0) - win.editor.setTextCursor(tc) +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() - win.findBar.find_next() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor) - assert sel_cs1 == "alpha" - win.findBar.find_next() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor) - assert sel_cs2 == "alpha" - assert s_cs2 != s_cs1 # it's the other lowercase match +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") - # 5) Previous goes back to the earlier lowercase match - win.findBar.find_prev() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s_prev, e_prev, sel_prev = _cursor_info(win.editor) - assert sel_prev == "alpha" - assert s_prev == s_cs1 + # 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) - # 6) Close returns focus to editor - win.findBar.closeBtn.click() - qtbot.waitUntil(lambda: not win.findBar.isVisible()) - qtbot.waitUntil(lambda: win.editor.hasFocus()) + 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 diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py new file mode 100644 index 0000000..da97a5a --- /dev/null +++ b/tests/test_history_dialog.py @@ -0,0 +1,311 @@ +from PySide6.QtWidgets import QWidget, QMessageBox, QApplication +from PySide6.QtCore import Qt, QTimer + +from bouquin.history_dialog import HistoryDialog + + +def test_history_dialog_lists_and_revert(qtbot, fresh_db): + d = "2001-01-01" + 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() + + 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 diff --git a/tests/test_history_dialog_revert_edges.py b/tests/test_history_dialog_revert_edges.py deleted file mode 100644 index f54e4d8..0000000 --- a/tests/test_history_dialog_revert_edges.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from PySide6.QtWidgets import QApplication, QListWidgetItem -from PySide6.QtCore import Qt - -from bouquin.db import DBConfig, DBManager -from bouquin.history_dialog import HistoryDialog - - -@pytest.fixture(scope="module") -def app(): - a = QApplication.instance() - if a is None: - a = QApplication([]) - return a - - -@pytest.fixture -def db(tmp_path): - cfg = DBConfig(path=tmp_path / "h.db", key="k") - db = DBManager(cfg) - assert db.connect() - # Seed two versions for a date - db.save_new_version("2025-02-10", "

v1

", note="v1", set_current=True) - db.save_new_version("2025-02-10", "

v2

", note="v2", set_current=True) - return db - - -def test_revert_early_returns(app, db, qtbot): - dlg = HistoryDialog(db, date_iso="2025-02-10") - qtbot.addWidget(dlg) - - # (1) No current item -> returns immediately - dlg.list.setCurrentItem(None) - dlg._revert() # should not crash and should not accept - - # (2) Selecting the current item -> still returns early - # Build an item with the *current* id as payload - cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"]) - it = QListWidgetItem("current") - it.setData(Qt.UserRole, cur_id) - dlg.list.addItem(it) - dlg.list.setCurrentItem(it) - dlg._revert() # should return early (no accept called) diff --git a/tests/test_history_dialog_unit.py b/tests/test_history_dialog_unit.py deleted file mode 100644 index 6689f5c..0000000 --- a/tests/test_history_dialog_unit.py +++ /dev/null @@ -1,66 +0,0 @@ -from PySide6.QtWidgets import QListWidgetItem -from PySide6.QtCore import Qt -from bouquin.history_dialog import HistoryDialog - - -class FakeDB: - def __init__(self): - self.fail_revert = False - - def list_versions(self, date_iso): - # Simulate two versions; mark second as current - return [ - { - "id": 1, - "version_no": 1, - "created_at": "2025-01-01T10:00:00Z", - "note": None, - "is_current": False, - "content": "

a

", - }, - { - "id": 2, - "version_no": 2, - "created_at": "2025-01-02T10:00:00Z", - "note": None, - "is_current": True, - "content": "

b

", - }, - ] - - def get_version(self, version_id): - if version_id == 2: - return {"content": "

b

"} - return {"content": "

a

"} - - def revert_to_version(self, date, version_id=None, version_no=None): - if self.fail_revert: - raise RuntimeError("boom") - - -def test_on_select_no_item(qtbot): - dlg = HistoryDialog(FakeDB(), "2025-01-01") - qtbot.addWidget(dlg) - dlg.list.clear() - dlg._on_select() - - -def test_revert_failure_shows_critical(qtbot, monkeypatch): - from PySide6.QtWidgets import QMessageBox - - fake = FakeDB() - fake.fail_revert = True - dlg = HistoryDialog(fake, "2025-01-01") - qtbot.addWidget(dlg) - item = QListWidgetItem("v1") - item.setData(Qt.UserRole, 1) # different from current 2 - dlg.list.addItem(item) - dlg.list.setCurrentItem(item) - msgs = {} - - def fake_crit(parent, title, text): - msgs["t"] = (title, text) - - monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit)) - dlg._revert() - assert "Revert failed" in msgs["t"][0] diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py new file mode 100644 index 0000000..f044fac --- /dev/null +++ b/tests/test_key_prompt.py @@ -0,0 +1,205 @@ +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") + 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 diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py new file mode 100644 index 0000000..05de5f9 --- /dev/null +++ b/tests/test_lock_overlay.py @@ -0,0 +1,17 @@ +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): + host = QWidget() + qtbot.addWidget(host) + host.show() + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + ol = LockOverlay(host, on_unlock=lambda: None, themes=themes) + qtbot.addWidget(ol) + ol.show() + + ev = QEvent(QEvent.Type.PaletteChange) + ol.changeEvent(ev) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..2a357fb --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,97 @@ +import importlib +import runpy +import pytest + + +def test_main_module_has_main(): + m = importlib.import_module("bouquin.main") + assert hasattr(m, "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 diff --git a/tests/test_main_module.py b/tests/test_main_module.py deleted file mode 100644 index 6d596b3..0000000 --- a/tests/test_main_module.py +++ /dev/null @@ -1,14 +0,0 @@ -import runpy -import types -import sys - - -def test_dunder_main_executes_without_launching_qt(monkeypatch): - # Replace bouquin.main with a stub that records invocation and returns immediately - calls = {"called": False} - mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True)) - monkeypatch.setitem(sys.modules, "bouquin.main", mod) - - # Running the module as __main__ should call mod.main() but not start a Qt loop - runpy.run_module("bouquin.__main__", run_name="__main__") - assert calls["called"] is True diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 0000000..bfe0972 --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,2448 @@ +import pytest +import importlib.metadata + +from datetime import date, timedelta +from pathlib import Path + +import bouquin.main_window as mwmod +from bouquin.main_window import MainWindow +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from bouquin.settings import get_settings +from bouquin.key_prompt import KeyPrompt +from bouquin.db import DBConfig, DBManager +from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect +from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog +from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent + +from unittest.mock import Mock, patch + +import bouquin.version_check as version_check + + +def test_main_window_loads_and_saves(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) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + date = QDate.currentDate().toString("yyyy-MM-dd") + w._load_selected_date(date) + w.editor.from_markdown("hello **world**") + w._on_text_changed() + qtbot.wait(5500) # let the 5s autosave QTimer fire + assert "world" in fresh_db.get_entry(date) + + w.search.search.setText("world") + qtbot.wait(50) + assert not w.search.results.isHidden() + + w._sync_toolbar() + w._adjust_day(-1) # previous day + w._adjust_day(+1) # next day + + # Auto-accept the unlock KeyPrompt with the correct key + def _auto_accept_keyprompt(): + for wdg in QApplication.topLevelWidgets(): + if isinstance(wdg, KeyPrompt): + wdg.key_entry.setText(tmp_db_cfg.key) + wdg.accept() + + w._enter_lock() + QTimer.singleShot(0, _auto_accept_keyprompt) + w._on_unlock_clicked() + qtbot.wait(50) # let the nested event loop process the acceptance + + +def test_load_yesterday_todos_moves_items(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) + s.setValue("ui/move_todos", True) + s.setValue("ui/theme", "light") + + y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") + fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + w._load_unchecked_todos() + + assert "carry me" in w.editor.to_markdown() + y_txt = fresh_db.get_entry(y) + assert "carry me" not in y_txt or "- [ ]" not in y_txt + + +def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Force QDesktopServices.openUrl to fail so the warning path executes + called = {"docs": False, "bugs": False} + + def fake_open(url): + # return False to force warning path + return False + + mwmod.QDesktopServices.openUrl = fake_open # minimal monkeypatch + + class DummyMB: + @staticmethod + def warning(parent, title, text, *rest): + t = str(text) + if "wiki" in t: + called["docs"] = True + return 0 + + monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings + + w._open_docs() + assert called["docs"] + + +def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): + # Seed some content + fresh_db.save_new_version("2001-01-01", "alpha", "n1") + fresh_db.save_new_version("2001-01-02", "beta", "n2") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + from bouquin.settings import get_settings + + s = get_settings() + s.setValue("db/default_db", str(fresh_db.cfg.path)) + s.setValue("db/key", fresh_db.cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Save as Markdown without extension -> should append .md and write file + dest1 = tmp_path / "export_one" # no suffix + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest1), "Markdown (*.md)")), + ) + + # Use real QMessageBox class; just force decisions and silence popups + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + # Critical should never trigger in the success path + monkeypatch.setattr( + mwmod.QMessageBox, + "critical", + staticmethod( + lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical")) + ), + raising=False, + ) + + w._export() + assert dest1.with_suffix(".md").exists() + + # Now force an exception during export to hit error branch (patch the window's DB) + def boom(): + raise RuntimeError("explode") + + monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False) + + # Different filename to avoid overwriting + dest2 = tmp_path / "export_two" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")), + ) + + errs = {"hit": False} + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, + "critical", + staticmethod(lambda *a, **k: errs.__setitem__("hit", True) or 0), + raising=False, + ) + w._export() + assert errs["hit"] + + +def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + # wire DB settings the window reads + from bouquin.settings import get_settings + + s = get_settings() + s.setValue("db/default_db", str(fresh_db.cfg.path)) + s.setValue("db/key", fresh_db.cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Pretend user picked a filename with no suffix -> .db should be appended + dest = tmp_path / "backupfile" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")), + raising=False, + ) + + # Avoid any modal dialogs and record the success message + hit = {"info": False, "text": None} + monkeypatch.setattr( + mwmod.QMessageBox, + "information", + staticmethod( + lambda parent, title, text, *a, **k: ( + hit.__setitem__("info", True), + hit.__setitem__("text", str(text)), + 0, + )[-1] + ), + raising=False, + ) + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False + ) + + # Stub the *export* itself to be instant and non-blocking + called = {"path": None} + monkeypatch.setattr( + w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False + ) + + w._backup() + + # Assertions: suffix added, export invoked, success toast shown + assert called["path"] == str(dest.with_suffix(".db")) + assert hit["info"] + assert str(dest.with_suffix(".db")) in hit["text"] + + +def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + from bouquin.settings import get_settings + + s = get_settings() + s.setValue("db/default_db", str(fresh_db.cfg.path)) + s.setValue("db/key", fresh_db.cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Create exactly one extra tab (there is already one from __init__) + d1 = QDate(2020, 1, 1) + w._open_date_in_tab(d1) + assert w.tab_widget.count() == 2 + + # Close one tab: should call _save_editor_content on its editor + saved = {"called": False} + + def fake_save_editor(editor): + saved["called"] = True + + monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True) + w._close_tab(0) + assert saved["called"] + # Now only one tab remains; closing should no-op + count_before = w.tab_widget.count() + w._close_tab(0) + assert w.tab_widget.count() == count_before + monkeypatch.delattr(w, "_save_editor_content", raising=False) + + +def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Case A: no geometry stored -> should call _move_to_cursor_screen_center + moved = {"hit": False} + monkeypatch.setattr( + w, + "_move_to_cursor_screen_center", + lambda: moved.__setitem__("hit", True), + raising=True, + ) + # clear any stored geometry + w.settings.remove("main/geometry") + w.settings.remove("main/windowState") + w.settings.remove("main/maximized") + w._restore_window_position() + assert moved["hit"] + + # Case B: geometry present but off-screen -> fallback to move_to_cursor + moved["hit"] = False + # Save a valid geometry then lie that it's offscreen + geom = w.saveGeometry() + w.settings.setValue("main/geometry", geom) + w.settings.setValue("main/windowState", w.saveState()) + w.settings.setValue("main/maximized", False) + monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True) + w._restore_window_position() + assert moved["hit"] + + # Case C: was_max True triggers showMaximized via QTimer.singleShot + called = {"max": False} + monkeypatch.setattr( + w, "showMaximized", lambda: called.__setitem__("max", True), raising=True + ) + monkeypatch.setattr( + mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False + ) + w.settings.setValue("main/maximized", True) + w._restore_window_position() + assert called["max"] + + +def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch): + # Seed DB so refresh marks does something + fresh_db.save_new_version("2021-08-15", "note", "") + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Show a month with leading days + qd = QDate(2021, 8, 15) + w.calendar.setSelectedDate(qd) + + # Grab the internal table view and pick a couple of indices + view = w.calendar.findChild(QTableView, "qt_calendar_calendarview") + model = view.model() + + # Find the first index belonging to current month (day == 1) + first_idx = None + for r in range(model.rowCount()): + for c in range(model.columnCount()): + if model.index(r, c).data() == 1: + first_idx = model.index(r, c) + break + if first_idx: + break + + assert first_idx is not None + + # A cell before 'first_idx' should map to previous month + col0 = 0 if first_idx.column() > 0 else 1 + idx_prev = model.index(first_idx.row(), col0) + vp_pos = view.visualRect(idx_prev).center() + global_pos = view.viewport().mapToGlobal(vp_pos) + cal_pos = w.calendar.mapFromGlobal(global_pos) + date_prev = w._date_from_calendar_pos(cal_pos) + assert isinstance(date_prev, QDate) and date_prev.isValid() + + # A cell after the last day should map to next month + last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day() + last_idx = None + for r in range(model.rowCount() - 1, -1, -1): + for c in range(model.columnCount() - 1, -1, -1): + if model.index(r, c).data() == last_day: + last_idx = model.index(r, c) + break + if last_idx: + break + assert last_idx is not None + + c_next = min(model.columnCount() - 1, last_idx.column() + 1) + idx_next = model.index(last_idx.row(), c_next) + vp_pos2 = view.visualRect(idx_next).center() + global_pos2 = view.viewport().mapToGlobal(vp_pos2) + cal_pos2 = w.calendar.mapFromGlobal(global_pos2) + date_next = w._date_from_calendar_pos(cal_pos2) + assert isinstance(date_next, QDate) and date_next.isValid() + + # Context menu path: return the "Open in New Tab" action + class DummyMenu: + def __init__(self, parent=None): + self._action = object() + + def addAction(self, text): + return self._action + + def exec_(self, *args, **kwargs): + return self._action + + monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True) + w._show_calendar_context_menu(cal_pos) + + +def test_event_filter_keypress_starts_idle_timer(qtbot, app): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + ev = QEvent(QEvent.KeyPress) + w.eventFilter(w, ev) + + +def _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db=None): + """Create a MainWindow wired to an existing db config so it doesn't prompt for key.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + # Force MainWindow to pick up our provided cfg + monkeypatch.setattr(mwmod, "load_db_config", lambda: tmp_db_cfg, raising=True) + # Make sure DB connects fine + if fresh_db is None: + fresh_db = DBManager(tmp_db_cfg) + assert fresh_db.connect() + w = MainWindow(themes) + return w + + +def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + # cfg with empty key and non-existent path => first_time True + cfg = DBConfig( + path=tmp_path / "does_not_exist.db", + key="", + idle_minutes=0, + theme="light", + move_todos=True, + ) + monkeypatch.setattr(mwmod, "load_db_config", lambda: cfg, raising=True) + + # Avoid accidentaly creating DB by short-circuiting the prompt loop + class MW(MainWindow): + def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802 + assert first_time is True + return False + + with pytest.raises(SystemExit): + MW(themes) + + +@pytest.mark.parametrize( + "msg, expect_key_msg", + [ + ("file is not a database", True), + ("totally unrelated", False), + ], +) +def test_try_connect_maps_errors( + qtbot, tmp_db_cfg, app, monkeypatch, msg, expect_key_msg +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make connect() raise + def boom(self): + raise Exception(msg) + + monkeypatch.setattr(mwmod.DBManager, "connect", boom, raising=True) + + shown = {} + + def fake_critical(parent, title, text, *a, **k): + shown["title"] = title + shown["text"] = str(text) + return 0 + + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True + ) + + w._try_connect() + + # And we still showed the right error message + assert "database" in shown["title"].lower() + if expect_key_msg: + assert "key" in shown["text"].lower() + else: + assert "unrelated" in shown["text"].lower() + # If closeEvent later explodes here, that is an app bug (should guard partial init). + + +# ---- _prompt_for_key_until_valid (324-332) ---- + + +def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class CancelPrompt: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Rejected + + def key(self): + return "" + + def db_path(self) -> Path | None: + return "foo.db" + + monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True) + assert w._prompt_for_key_until_valid(first_time=False) is False + + +def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class OKPrompt: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + def key(self): + return "abc" + + def db_path(self) -> Path | None: + return "foo.db" + + monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True) + monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True) + assert w._prompt_for_key_until_valid(first_time=True) is True + assert w.cfg.key == "abc" + + +# ---- Tabs/date management (368, 383, 388-391, 427-428, …) ---- + + +def test_reorder_tabs_by_date_and_tab_lookup(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + + e2 = w._create_new_tab(d2) + e3 = w._create_new_tab(d3) + e1 = w._create_new_tab(d1) # out of order on purpose + + # Force a reorder and verify ascending order for the three we added. + w._reorder_tabs_by_date() + order = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + today = QDate.currentDate().toString("yyyy-MM-dd") + order_wo_today = [d for d in order if d != today] + assert order_wo_today[:3] == ["2024-01-01", "2024-01-10", "2024-02-01"] + + # _tab_index_for_date finds existing and returns -1 for absent + assert w._tab_index_for_date(d2) != -1 + assert w._tab_index_for_date(QDate.fromString("1999-12-31", "yyyy-MM-dd")) == -1 + assert e1 is not None and e2 is not None and e3 is not None + + +def test_open_close_tabs_and_focus(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + today = QDate.currentDate() + ed = w._open_date_in_tab(today) # creates new if needed + assert hasattr(ed, "current_date") + # make another tab to allow closing + w._create_new_tab(today.addDays(1)) + count = w.tab_widget.count() + w._close_tab(0) + assert w.tab_widget.count() == count - 1 + + +# ---- _set_editor_markdown_preserve_view & load with extra_data (631) ---- + + +def test_load_selected_date_appends_extra_data_with_newline( + qtbot, tmp_db_cfg, app, monkeypatch, fresh_db +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db) + qtbot.addWidget(w) + w.db.save_new_version("2020-01-01", "first line", "seed") + w._load_selected_date("2020-01-01", extra_data="- [ ] carry") + assert "carry" in w.editor.toPlainText() + + +# ---- _save_editor_content early return (679) ---- + + +def test_save_editor_content_returns_without_current_date( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + from PySide6.QtWidgets import QTextEdit + + other = QTextEdit() + w._save_editor_content(other) # should early-return, not crash + + +# ---- _adjust_today creates a tab (695-696) ---- + + +def test_adjust_today_creates_tab(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._adjust_today() + today = QDate.currentDate().toString("yyyy-MM-dd") + assert any(w.tab_widget.tabText(i) == today for i in range(w.tab_widget.count())) + + +# ---- _on_date_changed guard for context menus (745) ---- + + +def test_on_date_changed_early_return_when_context_menu( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._showing_context_menu = True + # Should not throw or load + w._on_date_changed() + assert w._showing_context_menu is True # not toggled here + + +# ---- _save_current explicit paths (793-801, 809-810) ---- + + +def test_save_current_explicit_cancel(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class CancelSave: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Rejected + + def note_text(self): + return "" + + monkeypatch.setattr(mwmod, "SaveDialog", CancelSave, raising=True) + # returns early, does not raise + w._save_current(explicit=True) + + +def test_save_current_explicit_accept(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class OKSave: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + def note_text(self): + return "manual" + + monkeypatch.setattr(mwmod, "SaveDialog", OKSave, raising=True) + w._save_current(explicit=True) # should start/restart timers without error + + +# ---- Search highlighting (852-854) ---- + + +def test_apply_search_highlights_adds_and_clears(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + d1 = QDate.currentDate() + d2 = d1.addDays(1) + w._apply_search_highlights({d1, d2}) + # now drop d2 so clear-path runs + w._apply_search_highlights({d1}) + fmt = w.calendar.dateTextFormat(d1) + assert fmt.background().style() != Qt.NoBrush + + +# ---- History dialog glue (956, 961-962) ---- + + +def test_open_history_accept_refreshes(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + called = {"load": False, "refresh": False} + + def fake_load(date_iso=None): + called["load"] = True + + def fake_refresh(): + called["refresh"] = True + + monkeypatch.setattr(w, "_load_selected_date", fake_load, raising=True) + monkeypatch.setattr(w, "_refresh_calendar_marks", fake_refresh, raising=True) + + class Dlg: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + monkeypatch.setattr(mwmod, "HistoryDialog", Dlg, raising=True) + w._open_history() + assert called["load"] and called["refresh"] + + +# ---- Insert image picker (974, 981-989) ---- + + +def test_on_insert_image_calls_editor_insert( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make a tiny PNG file + from PySide6.QtGui import QImage + + img_path = tmp_path / "tiny.png" + img = QImage(1, 1, QImage.Format_ARGB32) + img.fill(0xFFFFFFFF) + img.save(str(img_path)) + + called = [] + + def fake_picker(*a, **k): + return ([str(img_path)], "Images (*.png)") + + monkeypatch.setattr( + mwmod.QFileDialog, "getOpenFileNames", staticmethod(fake_picker), raising=True + ) + + def recorder(path): + called.append(Path(path)) + + monkeypatch.setattr(w.editor, "insert_image_from_path", recorder, raising=True) + + w._on_insert_image() + assert called and called[0].name == "tiny.png" + + +# ---- Export flow branches (1062, 1078-1107) ---- + + +@pytest.mark.parametrize( + "filter_label, method", + [ + ("JSON (*.json)", "export_json"), + ("CSV (*.csv)", "export_csv"), + ("HTML (*.html)", "export_html"), + ("Markdown (*.md)", "export_markdown"), + ("SQL (*.sql)", "export_sql"), + ], +) +def test_export_calls_correct_method( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path, filter_label, method +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Confirm 'Yes' using the real QMessageBox; patch methods only + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False + ) + + # Return a path without extension to exercise default-ext logic too + out = tmp_path / "out" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), filter_label)), + raising=True, + ) + + # Provide some entries + monkeypatch.setattr( + w.db, "get_all_entries", lambda: [("2024-01-01", "x")], raising=True + ) + + called = {"name": None} + + def mark(*a, **k): + called["name"] = method + + monkeypatch.setattr(w.db, method, mark, raising=True) + w._export() + assert called["name"] == method + + +def test_export_unknown_filter_raises_and_is_caught( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Pre-export confirmation: Yes + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + + out = tmp_path / "weird" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), "WUT (*.wut)")), + raising=True, + ) + + # Capture the critical call + seen = {} + + def critical(parent, title, text, *a, **k): + seen["title"] = title + seen["text"] = str(text) + return 0 + + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(critical), raising=False + ) + + # And stub entries + monkeypatch.setattr(w.db, "get_all_entries", lambda: [], raising=True) + + w._export() + assert "export" in seen["title"].lower() + + +# ---- Backup handler (1147-1148) ---- + + +def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Always choose a filename and the SQL option + out = tmp_path / "backup" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), "SQLCipher (*.db)")), + raising=True, + ) + + called = {"ok": False, "err": False} + + def ok_export(path): + called["ok"] = True + + def crit(parent, title, text, *a, **k): + called["err"] = True + return 0 + + # First success + monkeypatch.setattr(w.db, "export_sqlcipher", ok_export, raising=True) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(crit), raising=False + ) + w._backup() + assert called["ok"] + + # Then failure + def boom(path): + raise RuntimeError("nope") + + monkeypatch.setattr(w.db, "export_sqlcipher", boom, raising=True) + w._backup() + assert called["err"] + + +# ---- Help openers (1152-1169) ---- + + +def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + mwmod.QDesktopServices, "openUrl", staticmethod(lambda *a: False), raising=True + ) + seen = {"docs": False, "bugs": False} + + def warn(parent, title, text, *a, **k): + if "documentation" in title.lower(): + seen["docs"] = True + return 0 + + monkeypatch.setattr( + mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)}) + ) + w._open_docs() + + assert seen["docs"] + + +def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + called = {"title": None, "text": None, "check_called": False} + + # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses + class FakeMessageBox: + # provide the enum attributes the code references + Information = 0 + ActionRole = 1 + Close = 2 + + def __init__(self, parent=None): + self._parent = parent + self._icon = None + self._title = "" + self._text = "" + self._buttons = [] + self._clicked = None + + def setIcon(self, icon): + self._icon = icon + + def setIconPixmap(self, icon): + self._icon = icon + + def setWindowTitle(self, title): + self._title = title + called["title"] = title + + def setText(self, text): + self._text = text + called["text"] = text + + def addButton(self, *args, **kwargs): + # We don't care about the label/role, we just need a distinct object + btn = object() + self._buttons.append(btn) + return btn + + def exec(self): + # Simulate user clicking the *Close* button, i.e. the second button + if self._buttons: + # show_version_dialog adds buttons in order: + # 0 -> "Check for updates" + # 1 -> Close + self._clicked = self._buttons[-1] + + def clickedButton(self): + return self._clicked + + # Patch the QMessageBox used *inside* version_check.py + monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox) + + # Optional: track if check_for_updates would be called + def fake_check_for_updates(self): + called["check_called"] = True + + monkeypatch.setattr( + version_check.VersionChecker, "check_for_updates", fake_check_for_updates + ) + + # Call the entrypoint + w._open_version() + + # Assertions: title and text got set correctly + assert called["title"] is not None + assert "version" in called["title"].lower() + + version = importlib.metadata.version("bouquin") + assert version in called["text"] + + # And we simulated closing, so "Check for updates" should not have fired + assert called["check_called"] is False + + +# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ---- + + +def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # remove timer to hit early return + delattr(w, "_idle_timer") + w._apply_idle_minutes(5) # no crash + + # re-create a timer and simulate locking then disabling idle + w._idle_timer = QTimer(w) + w._idle_timer.setSingleShot(True) + w._locked = True + w._apply_idle_minutes(0) + assert not w._locked # unlocked and overlay hidden path covered + + +def test_event_filter_sets_context_flag_and_keystart( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + # Right-click on calendar with a real QMouseEvent + me = QMouseEvent( + QEvent.MouseButtonPress, + QPoint(0, 0), + Qt.RightButton, + Qt.RightButton, + Qt.NoModifier, + ) + w.eventFilter(w.calendar, me) + assert getattr(w, "_showing_context_menu", False) is True + + # KeyPress restarts idle timer when unlocked (real QKeyEvent) + w._locked = False + ke = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.NoModifier) + w.eventFilter(w, ke) + + +def test_unlock_exception_path(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + def boom(*a, **k): + raise RuntimeError("nope") + + monkeypatch.setattr(w, "_prompt_for_key_until_valid", boom, raising=True) + + captured = {} + + def critical(parent, title, text, *a, **k): + captured["title"] = title + return 0 + + monkeypatch.setattr( + mwmod, "QMessageBox", type("MB", (), {"critical": staticmethod(critical)}) + ) + w._on_unlock_clicked() + assert "unlock" in captured["title"].lower() + + +# ---- Focus helpers (1273-1275, 1290) ---- + + +def test_focus_editor_now_and_app_state(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w.show() + # Locked => early return + w._locked = True + w._focus_editor_now() + # Active window path (force isActiveWindow) + w._locked = False + monkeypatch.setattr(w, "isActiveWindow", lambda: True, raising=True) + w._focus_editor_now() + # App state callback path + w._on_app_state_changed(Qt.ApplicationActive) + + +# ---- _rect_on_any_screen false path (1039) ---- + + +def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + sc = QApplication.primaryScreen().availableGeometry() + far = QRect(sc.right() + 10_000, sc.bottom() + 10_000, 100, 100) + assert not w._rect_on_any_screen(far) + + +def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Create two dated tabs (out of order), plus an undated QWidget page. + d1 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + w._create_new_tab(d1) + w._create_new_tab(d2) + + misc = QWidget() + w.tab_widget.addTab(misc, "misc") + + # Reorder and check: dated tabs sorted first, undated kept after them + w._reorder_tabs_by_date() + labels = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + assert labels[0] <= labels[1] + assert labels[-1] == "misc" + + # Also cover lookup for an existing date + assert w._tab_index_for_date(d2) != -1 + + +def test_index_for_date_insert_positions(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + a = QDate.fromString("2024-01-01", "yyyy-MM-dd") + c = QDate.fromString("2024-03-01", "yyyy-MM-dd") + d = QDate.fromString("2024-04-01", "yyyy-MM-dd") + + def expected(): + key = (d.year(), d.month(), d.day()) + for i in range(w.tab_widget.count()): + ed = w.tab_widget.widget(i) + cur = getattr(ed, "current_date", None) + if isinstance(cur, QDate) and cur.isValid(): + if (cur.year(), cur.month(), cur.day()) > key: + return i + return w.tab_widget.count() + + w._create_new_tab(a) + w._create_new_tab(c) + + # B belongs between A and C + b = QDate.fromString("2024-02-01", "yyyy-MM-dd") + assert w._index_for_date_insert(b) == 1 + + # Date prior to first should insert at 0 + z = QDate.fromString("2023-12-31", "yyyy-MM-dd") + assert w._index_for_date_insert(z) == 0 + + # Date after last should append + d = QDate.fromString("2024-04-01", "yyyy-MM-dd") + assert w._index_for_date_insert(d) == expected() + + +def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Early return path + w._on_tab_changed(-1) + + # Create two tabs then make stop() raise to hit try/except guard + w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd")) + w._create_new_tab(QDate.fromString("2024-01-02", "yyyy-MM-dd")) + + def boom(): + raise RuntimeError("stop failed") + + monkeypatch.setattr(w._save_timer, "stop", boom, raising=True) + w._on_tab_changed(1) # should not raise + + +def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + before = w.tab_widget.count() + + class DummyMenu: + def __init__(self, *a, **k): + pass + + def addAction(self, *a, **k): + return object() + + def exec_(self, *a, **k): + return None # return no action + + monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True) + w._show_calendar_context_menu(w.calendar.rect().center()) # nothing should happen + assert w.tab_widget.count() == before + + +def test_export_cancel_then_empty_filename( + qtbot, app, tmp_db_cfg, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # 1) cancel at the confirmation dialog + class FullMB: + Yes = QMessageBox.Yes + No = QMessageBox.No + Warning = QMessageBox.Warning + + def __init__(self, *a, **k): + pass + + def setWindowTitle(self, *a): + pass + + def setText(self, *a): + pass + + def setStandardButtons(self, *a): + pass + + def setIcon(self, *a): + pass + + def show(self): + pass + + def adjustSize(self): + pass + + def exec(self): + return self.No + + @staticmethod + def information(*a, **k): + return 0 + + @staticmethod + def critical(*a, **k): + return 0 + + monkeypatch.setattr(mwmod, "QMessageBox", FullMB, raising=True) + w._export() # returns early on No + + # 2) Yes in confirmation, but user cancels file dialog (empty filename) + class YesOnly(FullMB): + def exec(self): + return self.Yes + + monkeypatch.setattr(mwmod, "QMessageBox", YesOnly, raising=True) + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: ("", "Markdown (*.md)")), + raising=False, + ) + w._export() # returns early at filename check + + +def test_set_editor_markdown_preserve_view_preserves( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + long = "line\n" * 200 + w.editor.from_markdown(long) + w.editor.verticalScrollBar().setValue(50) + w.editor.moveCursor(QTextCursor.End) + pos_before = w.editor.textCursor().position() + v_before = w.editor.verticalScrollBar().value() + + # Same markdown → no rewrite, caret/scroll restored + w._set_editor_markdown_preserve_view(long) + assert w.editor.textCursor().position() == pos_before + assert w.editor.verticalScrollBar().value() == v_before + + # Different markdown → rewritten but caret restoration still executes + changed = long + "extra\n" + w._set_editor_markdown_preserve_view(changed) + assert w.editor.to_markdown().endswith("extra\n") + + +def test_load_date_into_editor_with_extra_data_forces_save( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + called = {"iso": None, "explicit": None} + + def save_date(iso, explicit): + called.update(iso=iso, explicit=explicit) + + monkeypatch.setattr(w, "_save_date", save_date, raising=True) + d = QDate.fromString("2020-01-01", "yyyy-MM-dd") + w._load_date_into_editor(d, extra_data="- [ ] carry") + assert called["iso"] == "2020-01-01" and called["explicit"] is True + + +def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers moveTab for both dated and undated buckets.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Create out-of-order dated tabs + d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + w._create_new_tab(d1) + w._create_new_tab(d3) + w._create_new_tab(d2) + + # Also insert an "undated" plain QWidget at the front to force a move later + undated = QWidget() + w.tab_widget.insertTab(0, undated, "undated") + + moved = {"calls": []} + tb = w.tab_widget.tabBar() + + def spy_moveTab(frm, to): + moved["calls"].append((frm, to)) + # don't actually move; we just need the call for coverage + + monkeypatch.setattr(tb, "moveTab", spy_moveTab, raising=True) + + w._reorder_tabs_by_date() + assert moved["calls"] # both dated and undated moves should have occurred + + +def test_date_from_calendar_view_none(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers early return when calendar view can't be found.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + w.calendar, + "findChild", + lambda *a, **k: None, # pretend the internal view is missing + raising=False, + ) + assert w._date_from_calendar_pos(QPoint(1, 1)) is None + + +def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch): + """ + Covers early returns when first_index or last_index cannot be found + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class FakeModel: + def rowCount(self): + return 1 + + def columnCount(self): + return 1 + + class Idx: + def data(self): + return None # never equals first/last-day + + def index(self, *_): + return self.Idx() + + class FakeView: + def model(self): + return FakeModel() + + class VP: + def mapToGlobal(self, p): + return p + + def mapFrom(self, *_): # calendar-local -> viewport + return QPoint(0, 0) + + def viewport(self): + return self.VP() + + def indexAt(self, *_): + # return an *instance* whose isValid() returns False + return type("I", (), {"isValid": lambda self: False})() + + monkeypatch.setattr( + w.calendar, + "findChild", + lambda *a, **k: FakeView(), + raising=False, + ) + # Early return when first day (1) can't be found + assert w._date_from_calendar_pos(QPoint(5, 5)) is None + + +def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers DB not connected branch.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # window editor has a current_date; simulate a dropped connection + assert isinstance(w.db, DBManager) + w.db.conn = None + # no crash -> branch hit + w._save_editor_content(w.editor) + + +def test_on_date_changed_stops_timer_and_saves_prev_when_dirty( + qtbot, app, tmp_db_cfg, monkeypatch +): + """ + Covers the exception-protected _save_timer.stop() and + the prev-date save path. + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make timer.stop() raise so the except path is covered + w._dirty = True + w.editor.current_date = QDate.fromString("2024-01-01", "yyyy-MM-dd") + # make timer.stop() raise so the except path is exercised + monkeypatch.setattr( + w._save_timer, + "stop", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + raising=True, + ) + + saved = {"iso": None} + + def stub_save_date(iso, explicit=False, note=None): + saved["iso"] = iso + + monkeypatch.setattr(w, "_save_date", stub_save_date, raising=False) + + w._on_date_changed() + assert saved["iso"] == "2024-01-01" + + +def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers early return when toolbar is already bound.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._bind_toolbar() + # Call again; line is covered if this no-ops + w._bind_toolbar() + + +def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers the early return when user selects no files.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + mwmod.QFileDialog, + "getOpenFileNames", + staticmethod(lambda *a, **k: ([], "")), + raising=True, + ) + + # Ensure we would notice if it tried to insert anything + monkeypatch.setattr( + w.editor, + "insert_image_from_path", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not insert")), + raising=True, + ) + w._on_insert_image() + + +def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers set + start when minutes>0 and not locked.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._locked = False + + hit = {"start": False} + monkeypatch.setattr( + w._idle_timer, + "start", + lambda *a, **k: hit.__setitem__("start", True), + raising=True, + ) + w._apply_idle_minutes(7) + assert hit["start"] + + +def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch): + """ + Covers exception swallowing around settings writes & ensures close proceeds + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # A couple of tabs so _save_editor_content runs during close + w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd")) + + # Make settings.setValue raise for coverage + orig_set = w.settings.setValue + + def flaky_set(*a, **k): + if a[0] in ("main/geometry", "main/windowState"): + raise RuntimeError("boom") + return orig_set(*a, **k) + + monkeypatch.setattr(w.settings, "setValue", flaky_set, raising=True) + + # Should not crash + w.close() + + +def test_on_date_changed_ignored_when_context_menu_shown( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + # Simulate flag set by context menu to ensure early return path + w._showing_context_menu = True + w._on_date_changed() # should simply return without raising + assert True # reached + + +def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch): + called = {"save": False, "close": False} + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + + def bad_save(editor): + called["save"] = True + raise RuntimeError("boom") + + class DummyDB: + def __init__(self): + self.conn = object() # <-- make conn truthy so branch is taken + + def close(self): + called["close"] = True + raise RuntimeError("kaboom") + + class DummyTabs: + def count(self): + return 1 # <-- ensures the save loop runs + + def widget(self, i): + return object() # any non-None editor-like object + + # Patch the pieces the closeEvent checks + w.tab_widget = DummyTabs() + w.db = DummyDB() + monkeypatch.setattr(w, "_save_editor_content", bad_save, raising=True) + + # Fire the event + ev = QCloseEvent() + w.closeEvent(ev) + + assert called["save"] and called["close"] + + +# ============================================================================ +# Tag Save Handler Tests +# ============================================================================ + + +def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _do_tag_save when editor has current_date""" + # Skip the key prompt + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Set a date on the editor + date = QDate(2024, 1, 15) + window.editor.current_date = date + window.editor.from_markdown("Test content") + + # Call _do_tag_save + window._do_tag_save() + + # Should have saved + fresh_db.get_entry("2024-01-15") + # May or may not have content depending on timing, but should not crash + assert True + + +def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _do_tag_save when editor doesn't have current_date""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Remove current_date attribute + if hasattr(window.editor, "current_date"): + delattr(window.editor, "current_date") + + # Call _do_tag_save - should handle gracefully + window._do_tag_save() + + assert True + + +def test_main_window_on_tag_added_triggers_deferred_save( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test that _on_tag_added defers the save""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock QTimer.singleShot + with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer: + window._on_tag_added() + + # Should have called singleShot + mock_timer.assert_called_once() + args = mock_timer.call_args[0] + assert args[0] == 0 # Delay of 0 + assert callable(args[1]) # Callback function + + +# ============================================================================ +# Tag Activation Tests +# ============================================================================ + + +def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _on_tag_activated when passed a date string""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock _load_selected_date + window._load_selected_date = Mock() + + # Call with date format + window._on_tag_activated("2024-01-15") + + # Should have called _load_selected_date + window._load_selected_date.assert_called_once_with("2024-01-15") + + +def test_main_window_on_tag_activated_with_tag_name( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test _on_tag_activated when passed a tag name""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock the tag browser dialog (it's imported locally in the method) + with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.openDateRequested = Mock() + mock_instance.exec.return_value = QDialog.Accepted + mock_dialog.return_value = mock_instance + + # Call with tag name + window._on_tag_activated("worktag") + + # Should have opened dialog + mock_dialog.assert_called_once() + # Check focus_tag was passed + call_kwargs = mock_dialog.call_args[1] + assert call_kwargs.get("focus_tag") == "worktag" + + +# ============================================================================ +# Settings Path Change Tests +# ============================================================================ + + +def test_main_window_settings_path_change_success( + app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch +): + """Test changing database path in settings""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + new_path = tmp_path / "new.db" + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + # Create a new config with different path + new_cfg = Mock() + new_cfg.path = str(new_path) + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 15 + new_cfg.theme = "light" + new_cfg.move_todos = True + new_cfg.locale = "en" + new_cfg.font_size = 11 + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Mock _prompt_for_key_until_valid to return True + window._prompt_for_key_until_valid = Mock(return_value=True) + # Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection + window._load_selected_date = Mock() + window._refresh_calendar_marks = Mock() + + # Open settings + window._open_settings() + + # Path should have changed + assert window.cfg.path == str(new_path) + + +def test_main_window_settings_path_change_failure( + app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch +): + """Test failed database path change shows warning""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + new_path = tmp_path / "new.db" + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + new_cfg = Mock() + new_cfg.path = str(new_path) + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 15 + new_cfg.theme = "light" + new_cfg.move_todos = True + new_cfg.locale = "en" + new_cfg.font_size = 11 + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Mock _prompt_for_key_until_valid to return False (failure) + window._prompt_for_key_until_valid = Mock(return_value=False) + + # Mock QMessageBox.warning + with patch.object(QMessageBox, "warning") as mock_warning: + # Open settings + window._open_settings() + + # Warning should have been shown + mock_warning.assert_called_once() + + +def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test settings change without path change""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + old_path = window.cfg.path + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + # Create config with SAME path + new_cfg = Mock() + new_cfg.path = old_path + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 20 # Changed + new_cfg.theme = "dark" # Changed + new_cfg.move_todos = False # Changed + new_cfg.locale = "fr" # Changed + new_cfg.font_size = 12 # Changed + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Open settings + window._open_settings() + + # Settings should be updated but path didn't change + assert window.cfg.idle_minutes == 20 + assert window.cfg.theme == "dark" + assert window.cfg.path == old_path + assert window.cfg.font_size == 12 + + +def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test cancelling settings dialog""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + old_theme = window.cfg.theme + + # Mock the settings dialog to be rejected + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Rejected + mock_dialog.return_value = mock_instance + + # Open settings + window._open_settings() + + # Settings should NOT change + assert window.cfg.theme == old_theme + + +# ============================================================================ +# Update Tag Views Tests +# ============================================================================ + + +def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _update_tag_views_for_date""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Set tags for a date + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + # Update tag views + window._update_tag_views_for_date("2024-01-15") + + # Tags widget should have been updated + assert window.tags._current_date == "2024-01-15" + + +def test_main_window_update_tag_views_no_tags_widget( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test _update_tag_views_for_date when tags widget doesn't exist""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Remove tags widget + delattr(window, "tags") + + # Should handle gracefully + window._update_tag_views_for_date("2024-01-15") + + assert True + + +def test_main_window_with_tags_disabled(qtbot, app, tmp_path): + """Test MainWindow with tags disabled in config - covers line 319""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/tags", False) # Disable tags + s.setValue("ui/time_log", True) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Tags widget should be hidden + assert w.tags.isHidden() + + +def test_main_window_with_time_log_disabled(qtbot, app, tmp_path): + """Test MainWindow with time_log disabled in config - covers line 321""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/tags", True) + s.setValue("ui/time_log", False) # Disable time log + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Time log widget should be hidden + assert w.time_log.isHidden() + + +def test_export_csv_format(qtbot, app, tmp_path, monkeypatch): + """Test exporting to CSV format - covers export path lines""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Add some data + w.db.save_new_version("2024-01-01", "Test content", "test") + + # Mock file dialog to return CSV + dest = tmp_path / "export_test.csv" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")), + ) + + # Mock QMessageBox to auto-accept + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + + w._export() + assert dest.exists() + + +def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch): + """Test opening settings dialog and changing locale - covers settings dialog paths""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Mock the settings dialog to auto-accept + from bouquin.settings_dialog import SettingsDialog + + SettingsDialog.exec + + def fake_exec(self): + # Change locale before accepting + idx = self.locale_combobox.findData("fr") + if idx >= 0: + self.locale_combobox.setCurrentIndex(idx) + return mwmod.QDialog.Accepted + + monkeypatch.setattr(SettingsDialog, "exec", fake_exec) + + w._open_settings() + qtbot.wait(50) + + +def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch): + """Test opening statistics dialog - covers statistics dialog paths""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Add some data + w.db.save_new_version("2024-01-01", "Test content", "test") + + from bouquin.statistics_dialog import StatisticsDialog + + StatisticsDialog.exec + + def fake_exec(self): + # Just accept immediately + return mwmod.QDialog.Accepted + + monkeypatch.setattr(StatisticsDialog, "exec", fake_exec) + + w._open_statistics() + qtbot.wait(50) + + +def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch): + """Test opening bug report dialog""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + from bouquin.bug_report_dialog import BugReportDialog + + BugReportDialog.exec + + def fake_exec(self): + return mwmod.QDialog.Rejected + + monkeypatch.setattr(BugReportDialog, "exec", fake_exec) + + w._open_bugs() + qtbot.wait(50) + + +def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch): + """Test opening history dialog and restoring a version""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Add some data + date_str = QDate.currentDate().toString("yyyy-MM-dd") + w.db.save_new_version(date_str, "Version 1", "v1") + w.db.save_new_version(date_str, "Version 2", "v2") + + from bouquin.history_dialog import HistoryDialog + + def fake_exec(self): + # Simulate selecting first version and accepting + if self.list.count() > 0: + self.list.setCurrentRow(0) + self._revert() + return mwmod.QDialog.Accepted + + monkeypatch.setattr(HistoryDialog, "exec", fake_exec) + + w._open_history() + qtbot.wait(50) + + +def test_goto_today_button(qtbot, app, tmp_path): + """Test going to today's date""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Move to a different date + past_date = QDate.currentDate().addDays(-30) + w.calendar.setSelectedDate(past_date) + + # Go back to today + w._adjust_today() + qtbot.wait(50) + + assert w.calendar.selectedDate() == QDate.currentDate() + + +def test_adjust_font_size(qtbot, app, tmp_path): + """Test adjusting font size""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/font_size", 12) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + initial_size = w.editor.font().pointSize() + + # Increase font size + w._on_font_larger_requested() + qtbot.wait(50) + assert w.editor.font().pointSize() > initial_size + + # Decrease font size + w._on_font_smaller_requested() + qtbot.wait(50) + + +def test_calendar_date_selection(qtbot, app, tmp_path): + """Test selecting a date from calendar""" + db_path = tmp_path / "notebook.db" + s = get_settings() + s.setValue("db/default_db", str(db_path)) + s.setValue("db/key", "test-key") + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Select a specific date + test_date = QDate(2024, 6, 15) + w.calendar.setSelectedDate(test_date) + qtbot.wait(50) + + # The window should load that date + assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso()) + + +def test_main_window_without_reminders(qtbot, app, tmp_db_cfg): + """Test main window when reminders feature is disabled.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", False) # Disabled + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Verify reminders widget is hidden + assert window.upcoming_reminders.isHidden() + assert not window.toolBar.actAlarm.isVisible() + + +def test_main_window_without_time_log(qtbot, app, tmp_db_cfg): + """Test main window when time_log feature is disabled.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", False) # Disabled + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Verify time_log widget is hidden + assert window.time_log.isHidden() + assert not window.toolBar.actTimer.isVisible() + + +def test_main_window_without_tags(qtbot, app, tmp_db_cfg): + """Test main window when tags feature is disabled.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", False) # Disabled + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Verify tags widget is hidden + assert window.tags.isHidden() + + +def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db): + """Test closing the current tab via _close_current_tab.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Open multiple tabs + today = date.today().isoformat() + tomorrow = (date.today() + timedelta(days=1)).isoformat() + + window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd")) + window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd")) + + initial_count = window.tab_widget.count() + assert initial_count >= 2 + + # Close current tab + window._close_current_tab() + + # Verify tab was closed + assert window.tab_widget.count() == initial_count - 1 + + +def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db): + """Test parsing inline alarms from markdown (⏰ HH:MM format).""" + from PySide6.QtCore import QTime + + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Open today's date + today_qdate = QDate.currentDate() + window._open_date_in_tab(today_qdate) + + # Set content with a future alarm + future_time = QTime.currentTime().addSecs(3600) # 1 hour from now + alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}" + + # Set the editor's current_date attribute + window.editor.current_date = today_qdate + window.editor.setPlainText(alarm_text) + + # Clear any existing timers + window._reminder_timers = [] + + # Trigger alarm parsing + window._rebuild_reminders_for_today() + + # Verify timer was created (not DB reminder) + assert len(window._reminder_timers) > 0 + + +def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db): + """Test that invalid time formats are skipped.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Open today's date + today_qdate = QDate.currentDate() + window._open_date_in_tab(today_qdate) + + # Set content with invalid time + alarm_text = "Do something ⏰ 25:99" # Invalid time + + window.editor.current_date = today_qdate + window.editor.setPlainText(alarm_text) + + # Clear any existing timers + window._reminder_timers = [] + + # Trigger alarm parsing - should not crash + window._rebuild_reminders_for_today() + + # No timer should be created for invalid time + assert len(window._reminder_timers) == 0 + + +def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db): + """Test that past alarms are skipped.""" + from PySide6.QtCore import QTime + + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Open today's date + today_qdate = QDate.currentDate() + window._open_date_in_tab(today_qdate) + + # Set content with past time + past_time = QTime.currentTime().addSecs(-3600) # 1 hour ago + alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}" + + window.editor.current_date = today_qdate + window.editor.setPlainText(alarm_text) + + # Clear any existing timers + window._reminder_timers = [] + + # Trigger alarm parsing + window._rebuild_reminders_for_today() + + # Past alarms should not create timers + assert len(window._reminder_timers) == 0 + + +def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db): + """Test alarm with no text before emoji uses fallback.""" + from PySide6.QtCore import QTime + + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Open today's date + today_qdate = QDate.currentDate() + window._open_date_in_tab(today_qdate) + + # Set content with alarm but no text + future_time = QTime.currentTime().addSecs(3600) + alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}" + + window.editor.current_date = today_qdate + window.editor.setPlainText(alarm_text) + + # Clear any existing timers + window._reminder_timers = [] + + # Trigger alarm parsing + window._rebuild_reminders_for_today() + + # Timer should be created even without text + assert len(window._reminder_timers) > 0 + + +def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db): + """Test opening history when editor has content.""" + from unittest.mock import patch + + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Create some history + today = date.today().isoformat() + fresh_db.save_new_version(today, "v1", "note1") + fresh_db.save_new_version(today, "v2", "note2") + + # Open today's date + window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd")) + + # Open history + with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec: + mock_exec.return_value = False # User cancels + window._open_history() + + # HistoryDialog should have been created and shown + mock_exec.assert_called_once() diff --git a/tests/test_main_window_actions.py b/tests/test_main_window_actions.py deleted file mode 100644 index 2630830..0000000 --- a/tests/test_main_window_actions.py +++ /dev/null @@ -1,90 +0,0 @@ -from PySide6.QtCore import QDate -from bouquin.theme import ThemeManager -from bouquin.main_window import MainWindow -from bouquin.settings import save_db_config -from bouquin.db import DBManager - - -def _bootstrap_window(qapp, cfg): - # Ensure DB exists and key is valid in settings - mgr = DBManager(cfg) - assert mgr.connect() is True - save_db_config(cfg) - - themes = ThemeManager(qapp, cfg) - win = MainWindow(themes) - # Force an initial selected date - win.calendar.setSelectedDate(QDate.currentDate()) - return win - - -def test_move_todos_copies_unchecked(qapp, cfg, tmp_path): - cfg.move_todos = True - win = _bootstrap_window(qapp, cfg) - - # Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects - y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") - html = ( - "

Unchecked 1

" - "

Checked 1

" - "

Unchecked 2

" - ) - win.db.save_new_version(y, html) - - # Ensure today starts blank - today_iso = QDate.currentDate().toString("yyyy-MM-dd") - win.editor.setHtml("

") - _html = win.editor.toHtml() - win.db.save_new_version(today_iso, _html) - - # Invoke the move-todos logic - win._load_yesterday_todos() - - # Verify today's entry now contains only the unchecked items - txt = win.db.get_entry(today_iso) - assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt - - -def test_adjust_and_save_paths(qapp, cfg): - win = _bootstrap_window(qapp, cfg) - - # Move date selection and jump to today - before = win.calendar.selectedDate() - win._adjust_day(-1) - assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString( - "yyyy-MM-dd" - ) - win._adjust_today() - assert win.calendar.selectedDate() == QDate.currentDate() - - # Save path exercises success feedback + dirty flag reset - win.editor.setHtml("

content

") - win._dirty = True - win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True) - assert win._dirty is False - - -def test_restore_window_position(qapp, cfg, tmp_path): - win = _bootstrap_window(qapp, cfg) - - # Save geometry/state into settings and restore it (covers maximize singleShot branch too) - geom = win.saveGeometry() - state = win.saveState() - s = win.settings - s.setValue("ui/geometry", geom) - s.setValue("ui/window_state", state) - s.sync() - - win._restore_window_position() # should restore without error - - -def test_idle_lock_unlock_flow(qapp, cfg): - win = _bootstrap_window(qapp, cfg) - - # Enter lock - win._enter_lock() - assert getattr(win, "_locked", False) is True - - # Disabling idle minutes should unlock and hide overlay - win._apply_idle_minutes(0) - assert getattr(win, "_locked", False) is False diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py new file mode 100644 index 0000000..cc02ad8 --- /dev/null +++ b/tests/test_markdown_editor.py @@ -0,0 +1,2438 @@ +import base64 +import pytest + +from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl +from PySide6.QtGui import ( + QImage, + QColor, + QKeyEvent, + QTextCursor, + QTextDocument, + QFont, + QTextCharFormat, +) +from PySide6.QtWidgets import QApplication, QTextEdit + +from bouquin.markdown_editor import MarkdownEditor +from bouquin.markdown_highlighter import MarkdownHighlighter +from bouquin.theme import ThemeManager, ThemeConfig, Theme + + +def _today(): + from datetime import date + + return date.today().isoformat() + + +def text(editor) -> str: + return editor.toPlainText() + + +def lines_keep(editor): + """Split preserving a trailing empty line if the text ends with '\\n'.""" + return text(editor).split("\n") + + +def press_backtick(qtbot, widget, n=1): + """Send physical backtick key events (avoid IME/dead-key issues).""" + for _ in range(n): + qtbot.keyClick(widget, Qt.Key_QuoteLeft) + + +@pytest.fixture +def editor(app, qtbot): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + ed = MarkdownEditor(themes) + qtbot.addWidget(ed) + ed.show() + qtbot.waitExposed(ed) + ed.setFocus() + return ed + + +@pytest.fixture +def editor_hello(app): + tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + e = MarkdownEditor(tm) + e.setPlainText("hello") + e.moveCursor(QTextCursor.MoveOperation.End) + return e + + +def test_from_and_to_markdown_roundtrip(editor): + md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```" + editor.from_markdown(md) + out = editor.to_markdown() + assert "Title" in out and "task" in out and "code" in out + + +def test_apply_styles_and_headings(editor, qtbot): + editor.from_markdown("hello world") + editor.selectAll() + editor.apply_weight() + editor.apply_italic() + editor.apply_strikethrough() + editor.apply_heading(24) + md = editor.to_markdown() + assert "**" in md and "*~~~~*" in md + + +def test_toggle_lists_and_checkboxes(editor): + editor.from_markdown("item one\nitem two\n") + editor.toggle_bullets() + assert "- " in editor.to_markdown() + editor.toggle_numbers() + assert "1. " in editor.to_markdown() + editor.toggle_checkboxes() + md = editor.to_markdown() + assert "- [ ]" in md or "- [x]" in md + + +def test_insert_image_from_path(editor, tmp_path): + img = tmp_path / "pic.png" + qimg = QImage(2, 2, QImage.Format_RGBA8888) + qimg.fill(QColor(255, 0, 0)) + assert qimg.save(str(img)) # ensure a valid PNG on disk + + editor.insert_image_from_path(img) + md = editor.to_markdown() + # Accept either "image/png" or older "image/image/png" prefix + assert "data:image/png;base64" in md or "data:image/image/png;base64" in md + + +def test_checkbox_toggle_by_click(editor, qtbot): + # Load a markdown checkbox + editor.from_markdown("- [ ] task here") + # Verify display token present + display = editor.toPlainText() + assert "☐" in display + + # Click on the first character region to toggle + c = editor.textCursor() + c.movePosition(QTextCursor.StartOfBlock) + editor.setTextCursor(c) + r = editor.cursorRect() + center = r.center() + pos = QPoint(r.left() + 2, center.y()) + qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos) + + # Should have toggled to checked icon + display2 = editor.toPlainText() + assert "☑" in display2 + + +def test_apply_heading_levels(editor, qtbot): + editor.setPlainText("hello") + editor.selectAll() + # H2 + editor.apply_heading(18) + assert editor.toPlainText().startswith("## ") + # H3 + editor.selectAll() + editor.apply_heading(14) + assert editor.toPlainText().startswith("### ") + # Normal (no heading) + editor.selectAll() + editor.apply_heading(12) + assert not editor.toPlainText().startswith("#") + + +def test_enter_on_nonempty_list_continues(qtbot, editor): + qtbot.addWidget(editor) + editor.show() + editor.from_markdown("- item") + c = editor.textCursor() + c.movePosition(QTextCursor.End) + editor.setTextCursor(c) + + ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(ev) + txt = editor.toPlainText() + assert "\n\u2022 " in txt + + +def test_enter_on_empty_list_marks_empty(qtbot, editor): + qtbot.addWidget(editor) + editor.show() + editor.from_markdown("- ") + c = editor.textCursor() + c.movePosition(QTextCursor.End) + editor.setTextCursor(c) + + ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(ev) + assert editor.toPlainText().startswith("\u2022 \n") + + +def test_triple_backtick_autoexpands(editor, qtbot): + editor.from_markdown("") + press_backtick(qtbot, editor, 2) + press_backtick(qtbot, editor, 1) # triggers expansion + qtbot.wait(0) + + t = text(editor) + assert t.count("```") == 2 + assert t.startswith("```\n\n```") + assert t.endswith("\n") + # caret is on the blank line inside the block + assert editor.textCursor().blockNumber() == 1 + assert lines_keep(editor)[1] == "" + + +def test_toolbar_inserts_block_on_own_lines(editor, qtbot): + editor.from_markdown("hello") + editor.moveCursor(QTextCursor.End) + editor.apply_code() # action inserts fenced code block + qtbot.wait(0) + + t = text(editor) + assert "hello```" not in t # never inline + assert t.startswith("hello\n```") + assert t.endswith("```\n") + # caret inside block (blank line) + assert editor.textCursor().blockNumber() == 2 + assert lines_keep(editor)[2] == "" + + +def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot): + editor.from_markdown("") + editor.apply_code() # create a block (caret now on blank line inside) + qtbot.wait(0) + + pos_before = editor.textCursor().position() + t_before = text(editor) + + editor.apply_code() # pressing inside should be a no-op + qtbot.wait(0) + + assert text(editor) == t_before + assert editor.textCursor().position() == pos_before + + +def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot): + editor.from_markdown("") + editor.apply_code() + qtbot.wait(0) + + # Go to opening fence (line 0) + editor.moveCursor(QTextCursor.Start) + editor.apply_code() # should jump inside the block + qtbot.wait(0) + + assert editor.textCursor().blockNumber() == 1 + assert lines_keep(editor)[1] == "" + + +def test_toolbar_on_closing_fence_jumps_out(editor, qtbot): + editor.from_markdown("") + editor.apply_code() + qtbot.wait(0) + + # Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after) + editor.moveCursor(QTextCursor.End) # blank-after + editor.moveCursor(QTextCursor.Up) # closing fence + editor.moveCursor(QTextCursor.StartOfLine) + + editor.apply_code() # jump to the line after the fence + qtbot.wait(0) + + # Now on the blank line after the block + assert editor.textCursor().block().text() == "" + assert editor.textCursor().block().previous().text().strip() == "```" + + +def test_down_escapes_from_last_code_line(editor, qtbot): + editor.from_markdown("```\nLINE\n```\n") + # Put caret at end of "LINE" + editor.moveCursor(QTextCursor.Start) + editor.moveCursor(QTextCursor.Down) # "LINE" + editor.moveCursor(QTextCursor.EndOfLine) + + qtbot.keyPress(editor, Qt.Key_Down) # hop after closing fence + qtbot.wait(0) + + # caret now on the blank line after the fence + assert editor.textCursor().block().text() == "" + assert editor.textCursor().block().previous().text().strip() == "```" + + +def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot): + editor.from_markdown("```\ncode\n```") # no trailing newline + # caret on closing fence line + editor.moveCursor(QTextCursor.End) + editor.moveCursor(QTextCursor.StartOfLine) + + qtbot.keyPress(editor, Qt.Key_Down) # should append newline and move there + qtbot.wait(0) + + # Do NOT use splitlines() here—preserve trailing blank line + assert text(editor).endswith("\n") + assert editor.textCursor().block().text() == "" # on the new blank line + assert editor.textCursor().block().previous().text().strip() == "```" + + +def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot): + editor.from_markdown("") + # create a block via typing + press_backtick(qtbot, editor, 3) + qtbot.keyClicks(editor, "x") + qtbot.keyPress(editor, Qt.Key_Down) # escape + editor.apply_code() # add second block via toolbar + qtbot.wait(0) + + # ensure there are no stray "``" lines + assert not any(ln.strip() == "``" for ln in lines_keep(editor)) + + +def _fmt_at(block, pos): + """Return a *copy* of the char format at pos so it doesn't dangle.""" + layout = block.layout() + for fr in list(layout.formats()): + if fr.start <= pos < fr.start + fr.length: + return QTextCharFormat(fr.format) + return None + + +@pytest.fixture +def highlighter(app): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + hl = MarkdownHighlighter(doc, themes) + return doc, hl + + +def test_headings_and_inline_styles(highlighter): + doc, hl = highlighter + doc.setPlainText("# H1\n## H2\n### H3\n***b+i*** **b** *i* __b__ _i_\n") + hl.rehighlight() + + # H1: '#' markers hidden (very small size), text bold/larger + b0 = doc.findBlockByNumber(0) + fmt_marker = _fmt_at(b0, 0) + assert fmt_marker is not None + assert fmt_marker.fontPointSize() <= 0.2 # marker hidden + + fmt_h1_text = _fmt_at(b0, 2) + assert fmt_h1_text is not None + assert fmt_h1_text.fontWeight() == QFont.Weight.Bold + + # Bold-italic precedence + b3 = doc.findBlockByNumber(3) + line = b3.text() + triple = "***b+i***" + start = line.find(triple) + assert start != -1 + pos_inside = start + 3 # skip the *** markers, land on 'b' + f_bi_inner = _fmt_at(b3, pos_inside) + assert f_bi_inner is not None + assert f_bi_inner.fontWeight() == QFont.Weight.Bold and f_bi_inner.fontItalic() + + # Bold without triples + f_b = _fmt_at(b3, b3.text().find("**b**") + 2) + assert f_b.fontWeight() == QFont.Weight.Bold + + # Italic without bold + f_i = _fmt_at(b3, b3.text().rfind("_i_") + 1) + assert f_i.fontItalic() + + +def test_code_blocks_inline_code_and_strike_overlay(highlighter): + doc, hl = highlighter + doc.setPlainText("```\n**B**\n```\nX ~~**boom**~~ Y `code`\n") + hl.rehighlight() + + # Fence and inner lines use code block format + fence = doc.findBlockByNumber(0) + inner = doc.findBlockByNumber(1) + + fmt_fence = _fmt_at(fence, 0) + fmt_inner = _fmt_at(inner, 0) + assert fmt_fence is not None and fmt_inner is not None + + # check key properties + assert fmt_inner.fontFixedPitch() or fmt_inner.font().styleHint() == QFont.Monospace + assert fmt_inner.background() == hl.code_block_format.background() + + # Inline code uses fixed pitch and hides the backticks + inline = doc.findBlockByNumber(3) + start = inline.text().find("`code`") + + fmt_inline_char = _fmt_at(inline, start + 1) + fmt_inline_tick = _fmt_at(inline, start) + assert fmt_inline_char is not None and fmt_inline_tick is not None + assert fmt_inline_char.fontFixedPitch() + assert fmt_inline_tick.fontPointSize() <= 0.2 # backtick hidden + + boom_pos = inline.text().find("boom") + fmt_boom = _fmt_at(inline, boom_pos) + assert fmt_boom is not None + assert fmt_boom.fontStrikeOut() and fmt_boom.fontWeight() == QFont.Weight.Bold + + +def test_theme_change_rehighlight(highlighter): + doc, hl = highlighter + hl._on_theme_changed() + doc.setPlainText("`x`") + hl.rehighlight() + b = doc.firstBlock() + fmt = _fmt_at(b, 1) + assert fmt is not None and fmt.fontFixedPitch() + + +@pytest.fixture +def hl_light(app): + # Light theme path + tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + hl = MarkdownHighlighter(doc, tm) + return doc, hl + + +@pytest.fixture +def hl_light_edit(app, qtbot): + tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + edit = QTextEdit() # <-- give the doc a layout + edit.setDocument(doc) + qtbot.addWidget(edit) + edit.show() + qtbot.wait(10) # let Qt build the layouts + hl = MarkdownHighlighter(doc, tm) + return doc, hl, edit + + +def fmt(doc, block_no, pos): + """Return the QTextCharFormat at character position `pos` in the given block.""" + b = doc.findBlockByNumber(block_no) + it = b.begin() + off = 0 + while not it.atEnd(): + frag = it.fragment() + length = frag.length() # includes chars in this fragment + if off + length > pos: + return frag.charFormat() + off += length + it = it.next() + # Fallback (shouldn't happen in our tests) + cf = QTextCharFormat() + return cf + + +def test_light_palette_specific_colors(hl_light_edit, qtbot): + doc, hl, edit = hl_light_edit + doc.setPlainText("```\ncode\n```") + hl.rehighlight() + # the second block ("code") is the one inside the fenced block + b_code = doc.firstBlock().next() + fmt = _fmt_at(b_code, 0) + assert fmt is not None and fmt.background().style() != 0 + + +def test_code_block_light_colors(hl_light): + """Ensure code block colors use the light palette (covers 74-75).""" + doc, hl = hl_light + doc.setPlainText("```\ncode\n```") + hl.rehighlight() + # Background is a light gray and text is dark/black-ish in light theme + bg = hl.code_block_format.background().color() + fg = hl.code_block_format.foreground().color() + assert bg.red() >= 240 and bg.green() >= 240 and bg.blue() >= 240 + assert fg.red() < 40 and fg.green() < 40 and fg.blue() < 40 + + +def test_end_guard_skips_italic_followed_by_marker(hl_light): + """ + Triggers the end-following guard for italic e.g. '*i**'. + """ + doc, hl = hl_light + doc.setPlainText("*i**") + hl.rehighlight() + # The 'i' should not get italic due to the guard (closing '*' followed by '*') + f = fmt(doc, 0, 1) + assert not f.fontItalic() + + +def test_char_rect_at_edges_and_click_checkbox(editor, qtbot): + """ + Exercises char_rect_at()-style logic and checkbox toggle via click + to push coverage on geometry-dependent paths. + """ + editor.from_markdown("- [ ] task") + c = editor.textCursor() + c.movePosition(QTextCursor.StartOfBlock) + editor.setTextCursor(c) + r = editor.cursorRect() + qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=r.center()) + assert "☑" in editor.toPlainText() + + +def test_heading_apply_levels_and_inline_styles(editor): + editor.setPlainText("hello") + editor.selectAll() + editor.apply_heading(18) # H2 + assert editor.toPlainText().startswith("## ") + editor.selectAll() + editor.apply_heading(12) # normal + assert not editor.toPlainText().startswith("#") + + # Bold/italic/strike together to nudge style branches + editor.setPlainText("hi") + editor.selectAll() + editor.apply_weight() + editor.apply_italic() + editor.apply_strikethrough() + md = editor.to_markdown() + assert "**" in md and "*" in md and "~~" in md + + +def test_insert_image_and_markdown_roundtrip(editor, tmp_path): + img = tmp_path / "p.png" + qimg = QImage(2, 2, QImage.Format_RGBA8888) + qimg.fill(QColor(255, 0, 0)) + assert qimg.save(str(img)) + editor.insert_image_from_path(img) + # At least a replacement char shows in the plain-text view + assert "\ufffc" in editor.toPlainText() + # And markdown contains a data: URI + assert "data:image" in editor.to_markdown() + + +def test_apply_italic_and_strike(editor): + # Italic: insert markers with no selection and place caret in between + editor.setPlainText("x") + editor.moveCursor(QTextCursor.MoveOperation.End) + editor.apply_italic() + assert editor.toPlainText().endswith("x**") + assert editor.textCursor().position() == len(editor.toPlainText()) - 1 + + # With selection toggling + editor.setPlainText("*y*") + c = editor.textCursor() + c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.MoveAnchor) + c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(c) + editor.apply_italic() + assert editor.toPlainText() == "y" + + # Strike: no selection case inserts placeholder and moves caret + editor.setPlainText("z") + editor.moveCursor(QTextCursor.MoveOperation.End) + editor.apply_strikethrough() + assert editor.toPlainText().endswith("z~~~~") + assert editor.textCursor().position() == len(editor.toPlainText()) - 2 + + +def test_apply_code_inline_block_navigation(editor): + # Selection case -> fenced block around selection + editor.setPlainText("code") + c = editor.textCursor() + c.select(QTextCursor.SelectionType.Document) + editor.setTextCursor(c) + editor.apply_code() + assert "```\ncode\n```\n" in editor.toPlainText() + + # No selection, at EOF with no following block -> creates block and extra newline path + editor.setPlainText("before") + editor.moveCursor(QTextCursor.MoveOperation.End) + editor.apply_code() + t = editor.toPlainText() + assert t.endswith("before\n```\n\n```\n") + # Caret should be inside the code block blank line + assert editor.textCursor().position() == len("before\n") + 4 + + +def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): + # Non-existent path should just return (early exit) + bad = tmp_path / "missing.png" + editor_hello.insert_image_from_path(bad) + # Nothing new added + assert editor_hello.toPlainText() == "hello" + + +# ============================================================================ +# setDocument Tests +# ============================================================================ + + +def test_markdown_editor_set_document(app): + """Test setting a new document on the editor""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Create a new document + new_doc = QTextDocument() + new_doc.setPlainText("New document content") + + # Set the document + editor.setDocument(new_doc) + + # Verify document was set + assert editor.document() == new_doc + assert "New document content" in editor.toPlainText() + + +def test_markdown_editor_set_document_with_highlighter(app): + """Test setting document preserves highlighter""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Ensure highlighter exists + assert hasattr(editor, "highlighter") + + # Create and set new document + new_doc = QTextDocument() + new_doc.setPlainText("# Heading") + editor.setDocument(new_doc) + + # Highlighter should be attached to new document + assert editor.highlighter.document() == new_doc + + +# ============================================================================ +# showEvent Tests +# ============================================================================ + + +def test_markdown_editor_show_event(app, qtbot): + """Test showEvent triggers code block background update""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Show the editor + editor.show() + qtbot.waitExposed(editor) + + # Process events to let QTimer.singleShot fire + QApplication.processEvents() + + # Editor should be visible + assert editor.isVisible() + + +# ============================================================================ +# Checkbox Transformation Tests +# ============================================================================ + + +def test_markdown_editor_transform_unchecked_checkbox(app, qtbot): + """Test transforming - [ ] to unchecked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type checkbox markdown + editor.insertPlainText("- [ ] Task") + + # Process events to let transformation happen + QApplication.processEvents() + + # Should contain checkbox character + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_checked_checkbox(app, qtbot): + """Test transforming - [x] to checked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type checked checkbox markdown + editor.insertPlainText("- [x] Done") + + # Process events + QApplication.processEvents() + + # Should contain checked checkbox character + text = editor.toPlainText() + assert editor._CHECK_CHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo(app, qtbot): + """Test transforming TODO to unchecked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO + editor.insertPlainText("TODO: Important task") + + # Process events + QApplication.processEvents() + + # Should contain checkbox and no TODO + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_indent(app, qtbot): + """Test transforming indented TODO""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type indented TODO + editor.insertPlainText(" TODO: Indented task") + + # Process events + QApplication.processEvents() + + # Should handle indented TODO + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_colon(app, qtbot): + """Test transforming TODO: with colon""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO with colon + editor.insertPlainText("TODO: Task with colon") + + # Process events + QApplication.processEvents() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_dash(app, qtbot): + """Test transforming TODO- with dash""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO with dash + editor.insertPlainText("TODO- Task with dash") + + # Process events + QApplication.processEvents() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_no_transform_when_updating(app): + """Test that transformation doesn't happen when _updating flag is set""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Set updating flag + editor._updating = True + + # Try to insert checkbox markdown + editor.insertPlainText("- [ ] Task") + + # Should NOT transform since _updating is True + # This tests the early return in _on_text_changed + assert editor._updating + + +# ============================================================================ +# Code Block Tests +# ============================================================================ + + +def test_markdown_editor_is_inside_code_block(app): + """Test detecting if cursor is inside code block""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode here\n```\noutside") + + # Move cursor to inside code block + cursor = editor.textCursor() + cursor.setPosition(10) # Inside the code block + editor.setTextCursor(cursor) + + block = cursor.block() + # Test the method exists and can be called + result = editor._is_inside_code_block(block) + assert isinstance(result, bool) + + +def test_markdown_editor_code_block_spacing(app): + """Test code block spacing application""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\nline1\nline2\n```") + + # Apply code block spacing + editor._apply_code_block_spacing() + + # Should complete without error + assert True + + +def test_markdown_editor_update_code_block_backgrounds(app): + """Test updating code block backgrounds""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Update backgrounds + editor._update_code_block_row_backgrounds() + + # Should complete without error + assert True + + +# ============================================================================ +# Image Insertion Tests +# ============================================================================ + + +def test_markdown_editor_insert_image_from_path(app, tmp_path): + """Test inserting image from file path""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Create a real PNG image (1x1 pixel) + # PNG file signature + minimal valid PNG data + png_data = ( + b"\x89PNG\r\n\x1a\n" # PNG signature + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" # IHDR chunk + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4" # IDAT chunk + b"\x00\x00\x00\x00IEND\xaeB`\x82" # IEND chunk + ) + image_path = tmp_path / "test.png" + image_path.write_bytes(png_data) + + # Insert image + editor.insert_image_from_path(image_path) + + # Check that document has content (image + newline) + # Images don't show in toPlainText() but affect document structure + doc = editor.document() + assert doc.characterCount() > 1 # Should have image char + newline + + +# ============================================================================ +# Formatting Tests +# ============================================================================ + + +def test_markdown_editor_toggle_bold_empty_selection(app): + """Test toggling bold with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + # Move cursor to middle of text (no selection) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Toggle bold (inserts ** markers with cursor between them) + editor.apply_weight() + + # Should have inserted bold markers + text = editor.toPlainText() + assert "**" in text + + # Should handle empty selection + assert True + + +def test_markdown_editor_toggle_italic_empty_selection(app): + """Test toggling italic with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + # Move cursor to middle (no selection) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Toggle italic + editor.apply_italic() + + # Should handle empty selection + assert True + + +def test_markdown_editor_toggle_strikethrough_empty_selection(app): + """Test toggling strikethrough with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + editor.apply_strikethrough() + + assert True + + +def test_markdown_editor_toggle_code_empty_selection(app): + """Test toggling code with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + editor.apply_code() + + assert True + + +# ============================================================================ +# Heading Tests +# ============================================================================ + + +def test_markdown_editor_set_heading_various_levels(app): + """Test setting different heading levels""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + for level in [14, 18, 24]: + editor.clear() + editor.insertPlainText("Heading text") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Set heading level + editor.apply_heading(level) + + # Should have heading markdown + text = editor.toPlainText() + assert "#" in text + + +def test_markdown_editor_set_heading_zero_removes_heading(app): + """Test setting heading level 0 removes heading""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("# Heading") + + # Select heading + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Set to level 0 (remove heading) + editor.apply_heading(0) + + # Should not have heading markers + text = editor.toPlainText() + assert not text.startswith("#") + + +# ============================================================================ +# List Tests +# ============================================================================ + + +def test_markdown_editor_toggle_list_bullet(app): + """Test toggling bullet list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Item 1\nItem 2") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle bullet list + editor.toggle_bullets() + + # Should have bullet markers + text = editor.toPlainText() + assert "•" in text or "-" in text + + +def test_markdown_editor_toggle_list_ordered(app): + """Test toggling ordered list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Item 1\nItem 2") + + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + editor.toggle_numbers() + + text = editor.toPlainText() + assert "1" in text or "2" in text + + +# ============================================================================ +# Code Block Tests +# ============================================================================ + + +def test_markdown_editor_apply_code_selected_text(app): + """Test toggling code block with selected text""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("def hello():\n print('hi')") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle code block + editor.apply_code() + + # Should have code fence + text = editor.toPlainText() + assert "```" in text + + +def test_markdown_editor_apply_code_remove(app): + """Test removing code block""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle off + editor.apply_code() + + # Code fences should be reduced/removed + editor.toPlainText() + # May still have ``` but different structure + assert True # Just verify no crash + + +# ============================================================================ +# Checkbox Tests +# ============================================================================ + + +def test_markdown_editor_insert_checkbox_unchecked(app): + """Test inserting unchecked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + editor.toggle_checkboxes() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +# ============================================================================ +# Toggle Checkboxes Tests +# ============================================================================ + + +def test_markdown_editor_toggle_checkboxes_none_selected(app): + """Test toggling checkboxes with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("☐ Task 1\n☐ Task 2") + + # No selection, just cursor + editor.toggle_checkboxes() + + # Should handle gracefully + assert True + + +def test_markdown_editor_toggle_checkboxes_mixed(app): + """Test toggling mixed checked/unchecked checkboxes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("☐ Task 1\n☑ Task 2\n☐ Task 3") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle + editor.toggle_checkboxes() + + # Should toggle all + text = editor.toPlainText() + assert ( + editor._CHECK_CHECKED_DISPLAY in text or editor._CHECK_UNCHECKED_DISPLAY in text + ) + + +# ============================================================================ +# Markdown Conversion Tests +# ============================================================================ + + +def test_markdown_editor_to_markdown_with_checkboxes(app): + """Test converting to markdown preserves checkboxes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("- [ ] Task 1\n- [x] Task 2") + + md = editor.to_markdown() + + # Should have checkbox markdown + assert "[ ]" in md or "[x]" in md + + +def test_markdown_editor_from_markdown_with_images(app): + """Test loading markdown with images""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + md_with_image = "# Title\n\n![alt text](image.png)\n\nText" + editor.from_markdown(md_with_image) + + # Should load without error + text = editor.toPlainText() + assert "Title" in text + + +def test_markdown_editor_from_markdown_with_links(app): + """Test loading markdown with links""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + md_with_link = "[Click here](https://example.com)" + editor.from_markdown(md_with_link) + + text = editor.toPlainText() + assert "Click here" in text + + +# ============================================================================ +# Selection and Cursor Tests +# ============================================================================ + + +def test_markdown_editor_select_word_under_cursor(app): + """Test selecting word under cursor""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Hello world test") + + # Move cursor to middle of word + cursor = editor.textCursor() + cursor.setPosition(7) # Middle of "world" + editor.setTextCursor(cursor) + + # Select word (via double-click or other mechanism) + cursor.select(QTextCursor.WordUnderCursor) + editor.setTextCursor(cursor) + + assert cursor.hasSelection() + + +def test_markdown_editor_get_selected_blocks(app): + """Test getting selected blocks""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Line 1\nLine 2\nLine 3") + + # Select multiple lines + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(14, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + # Should have selection + assert cursor.hasSelection() + + +# ============================================================================ +# Key Event Tests +# ============================================================================ + + +def test_markdown_editor_key_press_tab(app): + """Test tab key handling""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + + # Create tab key event + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier) + + # Send event + editor.keyPressEvent(event) + + # Should insert tab or spaces + text = editor.toPlainText() + assert len(text) > 0 or text == "" # Tab or spaces inserted + + +def test_markdown_editor_key_press_return_in_list(app): + """Test return key in list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("- Item 1") + + # Move cursor to end + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press return + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier) + editor.keyPressEvent(event) + + # Should create new list item + text = editor.toPlainText() + assert "Item 1" in text + + +# ============================================================================ +# Link Handling Tests +# ============================================================================ + + +def test_markdown_editor_anchor_at_cursor(app): + """Test getting anchor at cursor position""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("[link](https://example.com)") + + # Move cursor over link + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Get anchor (if any) + anchor = cursor.charFormat().anchorHref() + + # May or may not have anchor depending on rendering + assert isinstance(anchor, str) + + +def test_markdown_editor_mouse_move_over_link(app): + """Test mouse movement over link""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("[link](https://example.com)") + editor.show() + + # Simulate mouse move + # This tests viewport event handling + assert True # Just verify no crash + + +# ============================================================================ +# Theme Mode Tests +# ============================================================================ + + +def test_markdown_highlighter_light_mode(app): + """Test highlighter in light mode""" + doc = QTextDocument() + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + + # Check that light mode colors are set + bg = highlighter.code_block_format.background().color() + assert bg.isValid() + # Check it's a light color (high RGB values, close to 245) + assert bg.red() > 240 and bg.green() > 240 and bg.blue() > 240 + + fg = highlighter.code_block_format.foreground().color() + assert fg.isValid() + # Check it's a dark color for text + assert fg.red() < 50 and fg.green() < 50 and fg.blue() < 50 + + +def test_markdown_highlighter_dark_mode(app): + """Test highlighter in dark mode""" + doc = QTextDocument() + themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + highlighter = MarkdownHighlighter(doc, themes) + + # Check that dark mode uses palette colors + bg = highlighter.code_block_format.background().color() + fg = highlighter.code_block_format.foreground().color() + + assert bg.isValid() + assert fg.isValid() + + +# ============================================================================ +# Highlighting Pattern Tests +# ============================================================================ + + +def test_markdown_highlighter_triple_backtick_code(app): + """Test highlighting triple backtick code blocks""" + doc = QTextDocument() + doc.setPlainText("```python\ndef hello():\n pass\n```") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + + # Force rehighlight + highlighter.rehighlight() + + # Should complete without errors + assert True + + +def test_markdown_highlighter_inline_code(app): + """Test highlighting inline code with backticks""" + doc = QTextDocument() + doc.setPlainText("Here is `inline code` in text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_bold_text(app): + """Test highlighting bold text""" + doc = QTextDocument() + doc.setPlainText("This is **bold** text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_italic_text(app): + """Test highlighting italic text""" + doc = QTextDocument() + doc.setPlainText("This is *italic* text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_headings(app): + """Test highlighting various heading levels""" + doc = QTextDocument() + doc.setPlainText("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_links(app): + """Test highlighting markdown links""" + doc = QTextDocument() + doc.setPlainText("[link text](https://example.com)") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_images(app): + """Test highlighting markdown images""" + doc = QTextDocument() + doc.setPlainText("![alt text](image.png)") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_blockquotes(app): + """Test highlighting blockquotes""" + doc = QTextDocument() + doc.setPlainText("> This is a quote\n> Second line") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_lists(app): + """Test highlighting lists""" + doc = QTextDocument() + doc.setPlainText("- Item 1\n- Item 2\n- Item 3") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_ordered_lists(app): + """Test highlighting ordered lists""" + doc = QTextDocument() + doc.setPlainText("1. First\n2. Second\n3. Third") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_horizontal_rules(app): + """Test highlighting horizontal rules""" + doc = QTextDocument() + doc.setPlainText("Text above\n\n---\n\nText below") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_strikethrough(app): + """Test highlighting strikethrough text""" + doc = QTextDocument() + doc.setPlainText("This is ~~strikethrough~~ text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_mixed_formatting(app): + """Test highlighting mixed markdown formatting""" + doc = QTextDocument() + doc.setPlainText( + "# Title\n\nThis is **bold** and *italic* with `code`.\n\n- List item\n- Another item" + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_switch_dark_mode(app): + """Test that dark mode uses different colors than light mode""" + doc = QTextDocument() + doc.setPlainText("# Test") + + # Create light mode highlighter + themes_light = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter_light = MarkdownHighlighter(doc, themes_light) + light_bg = highlighter_light.code_block_format.background().color() + + # Create dark mode highlighter with new document (to avoid conflicts) + doc2 = QTextDocument() + doc2.setPlainText("# Test") + themes_dark = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + highlighter_dark = MarkdownHighlighter(doc2, themes_dark) + dark_bg = highlighter_dark.code_block_format.background().color() + + # In light mode, background should be light (high RGB values) + # In dark mode, background should be darker (lower RGB values) + # Note: actual values depend on system palette and theme settings + assert light_bg.isValid() + assert dark_bg.isValid() + + # At least one of these should be true (depending on system theme): + # - Light is lighter than dark, OR + # - Both are set to valid colors (if system theme overrides) + is_light_lighter = ( + light_bg.red() + light_bg.green() + light_bg.blue() + > dark_bg.red() + dark_bg.green() + dark_bg.blue() + ) + both_valid = light_bg.isValid() and dark_bg.isValid() + + assert is_light_lighter or both_valid # At least colors are being set + + +# ============================================================================ +# MarkdownHighlighter Tests - Missing Coverage +# ============================================================================ + + +def test_markdown_highlighter_code_block_detection(qtbot, app): + """Test code block detection and highlighting.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + # Set text with code block + text = """ +Some text +```python +def hello(): + pass +``` +More text +""" + doc.setPlainText(text) + + # The highlighter should process the text + # Just ensure no crash + assert highlighter is not None + + +def test_markdown_highlighter_headers(qtbot, app): + """Test header highlighting.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = """ +# Header 1 +## Header 2 +### Header 3 +Normal text +""" + doc.setPlainText(text) + + assert highlighter is not None + + +def test_markdown_highlighter_emphasis(qtbot, app): + """Test emphasis highlighting.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = "**bold** and *italic* and ***both***" + doc.setPlainText(text) + + assert highlighter is not None + + +def test_markdown_highlighter_horizontal_rule(qtbot, app): + """Test horizontal rule highlighting.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = """ +Text above +--- +Text below +*** +More text +___ +End +""" + doc.setPlainText(text) + + assert highlighter is not None + + +def test_markdown_highlighter_complex_document(qtbot, app): + """Test highlighting a complex document with mixed elements.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = """ +# Main Title + +This is a paragraph with **bold** and *italic* text. + +## Code Example + +Here's some `inline code` and a block: + +```python +def fibonacci(n): + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2) +``` + +## Lists + +- Item with *emphasis* +- Another item with **bold** +- [A link](https://example.com) + +> A blockquote with **formatted** text +> Second line + +--- + +### Final Section + +~~Strikethrough~~ and normal text. +""" + doc.setPlainText(text) + + # Should handle complex document + assert highlighter is not None + + +def test_markdown_highlighter_empty_document(qtbot, app): + """Test highlighting empty document.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + doc.setPlainText("") + + assert highlighter is not None + + +def test_markdown_highlighter_update_on_text_change(qtbot, app): + """Test that highlighter updates when text changes.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + doc.setPlainText("Initial text") + doc.setPlainText("# Header text") + doc.setPlainText("**Bold text**") + + # Should handle updates + assert highlighter is not None + + +def test_markdown_highlighter_nested_emphasis(qtbot, app): + """Test nested emphasis patterns.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = "This has **bold with *italic* inside** and more" + doc.setPlainText(text) + + assert highlighter is not None + + +def test_markdown_highlighter_unclosed_code_block(qtbot, app): + """Test handling of unclosed code block.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = """ +```python +def hello(): + print("world") +""" + doc.setPlainText(text) + + # Should handle gracefully + assert highlighter is not None + + +def test_markdown_highlighter_special_characters(qtbot, app): + """Test handling special characters in markdown.""" + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, theme_manager) + + text = """ +Special chars: < > & " ' +Escaped: \\* \\_ \\` +Unicode: 你好 café résumé +""" + doc.setPlainText(text) + + assert highlighter is not None + + +@pytest.mark.parametrize( + "markdown_line", + [ + "- [ ] Task", # checkbox + "- Task", # bullet + "1. Task", # numbered + ], +) +def test_home_on_list_line_moves_to_text_start(qtbot, editor, markdown_line): + """Home on a list line should jump to just after the list marker.""" + editor.from_markdown(markdown_line) + + # Put caret at end of the line + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press Home (no modifiers) + qtbot.keyPress(editor, Qt.Key_Home) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + # The first character of the user text is the 'T' in "Task" + logical_start = line.index("Task") + + assert not c.hasSelection() + assert pos_in_block == logical_start + + +@pytest.mark.parametrize( + "markdown_line", + [ + "- [ ] Task", # checkbox + "- Task", # bullet + "1. Task", # numbered + ], +) +def test_shift_home_on_list_line_selects_text_after_marker( + qtbot, editor, markdown_line +): + """ + Shift+Home from the end of a list line should select the text after the marker, + not the marker itself. + """ + editor.from_markdown(markdown_line) + + # Put caret at end of the line + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Shift+Home: extend selection back to "logical home" + qtbot.keyPress(editor, Qt.Key_Home, Qt.ShiftModifier) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + block_start = block.position() + + logical_start = line.index("Task") + expected_start = block_start + logical_start + expected_end = block_start + len(line) + + assert c.hasSelection() + assert c.selectionStart() == expected_start + assert c.selectionEnd() == expected_end + # Selected text is exactly the user-visible text, not the marker + assert c.selectedText() == line[logical_start:] + + +def test_up_from_below_checkbox_moves_to_text_start(qtbot, editor): + """ + Up from the line below a checkbox should land to the right of the checkbox, + where the text starts, not to the left of the marker. + """ + editor.from_markdown("- [ ] Task\nSecond line") + + # Put caret somewhere on the second line (end of document is fine) + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press Up to move to the checkbox line + qtbot.keyPress(editor, Qt.Key_Up) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + logical_start = line.index("Task") + assert pos_in_block >= logical_start + + +def test_backspace_on_empty_checkbox_removes_marker(qtbot, editor): + """ + When a checkbox line has no text after the marker, Backspace at/after the + text position should delete the marker itself, leaving a plain empty line. + """ + editor.from_markdown("- [ ] ") + + # Put caret at end of the checkbox line (after the marker) + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Backspace) + qtbot.wait(0) + + first_block = editor.document().firstBlock() + # Marker should be gone + assert first_block.text() == "" + assert editor._CHECK_UNCHECKED_DISPLAY not in editor.toPlainText() + + +def test_render_images_with_corrupted_data(qtbot, app): + """Test rendering images with corrupted data that creates null QImage""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + # Create some binary data that will decode but not form a valid image + corrupted_data = base64.b64encode(b"not an image file").decode("utf-8") + markdown = f"![corrupted](data:image/png;base64,{corrupted_data})" + + editor.from_markdown(markdown) + qtbot.wait(50) + + # Should still work without crashing + text = editor.to_markdown() + assert len(text) >= 0 + + +def test_editor_with_code_blocks(qtbot, app): + """Test editor with code blocks""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + code_markdown = """ +Some text + +```python +def hello(): + print("world") +``` + +More text +""" + editor.from_markdown(code_markdown) + qtbot.wait(50) + + result = editor.to_markdown() + assert "def hello" in result or "python" in result + + +def test_editor_undo_redo(qtbot, app): + """Test undo/redo functionality""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + # Type some text + editor.from_markdown("Initial text") + qtbot.wait(50) + + # Add more text + editor.insertPlainText(" additional") + qtbot.wait(50) + + # Undo + editor.undo() + qtbot.wait(50) + + # Redo + editor.redo() + qtbot.wait(50) + + assert len(editor.to_markdown()) > 0 + + +def test_editor_cut_copy_paste(qtbot, app): + """Test cut/copy/paste operations""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + editor.from_markdown("Test content for copy") + qtbot.wait(50) + + # Select all + editor.selectAll() + + # Copy + editor.copy() + qtbot.wait(50) + + # Move to end and paste + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + editor.paste() + qtbot.wait(50) + + # Should have content twice (or clipboard might be empty in test env) + assert len(editor.to_markdown()) > 0 + + +def test_editor_with_blockquotes(qtbot, app): + """Test editor with blockquotes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + quote_markdown = """ +Normal text + +> This is a quote +> With multiple lines + +More normal text +""" + editor.from_markdown(quote_markdown) + qtbot.wait(50) + + result = editor.to_markdown() + assert ">" in result or "quote" in result + + +def test_editor_with_horizontal_rules(qtbot, app): + """Test editor with horizontal rules""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + hr_markdown = """ +Section 1 + +--- + +Section 2 +""" + editor.from_markdown(hr_markdown) + qtbot.wait(50) + + result = editor.to_markdown() + assert "Section" in result + + +def test_editor_with_mixed_content(qtbot, app): + """Test editor with mixed markdown content""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + mixed_markdown = """ +# Heading + +This is **bold** and *italic* text. + +- [ ] Todo item +- [x] Completed item + +```python +code() +``` + +[Link](https://example.com) + +> Quote + +| Table | Header | +|-------|--------| +| A | B | +""" + editor.from_markdown(mixed_markdown) + qtbot.wait(50) + + result = editor.to_markdown() + # Should contain various markdown elements + assert len(result) > 50 + + +def test_editor_insert_text_at_cursor(qtbot, app): + """Test inserting text at cursor position""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + editor.from_markdown("Start Middle End") + qtbot.wait(50) + + # Move cursor to middle + cursor = editor.textCursor() + cursor.setPosition(6) + editor.setTextCursor(cursor) + + # Insert text + editor.insertPlainText("INSERTED ") + qtbot.wait(50) + + result = editor.to_markdown() + assert "INSERTED" in result + + +def test_editor_delete_operations(qtbot, app): + """Test delete operations""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(theme_manager=themes) + qtbot.addWidget(editor) + editor.show() + + editor.from_markdown("Text to delete") + qtbot.wait(50) + + # Select some text and delete + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(4, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + cursor.removeSelectedText() + qtbot.wait(50) + + result = editor.to_markdown() + assert "Text" not in result or len(result) < 15 + + +def test_markdown_highlighter_dark_theme(qtbot, app): + """Test markdown highlighter with dark theme - covers lines 74-75""" + # Create theme manager with dark theme + themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + + # Create a text document + doc = QTextDocument() + + # Create highlighter with dark theme + highlighter = MarkdownHighlighter(doc, themes) + + # Set some markdown text + doc.setPlainText("# Heading\n\nSome **bold** text\n\n```python\ncode\n```") + + # The highlighter should work with dark theme + assert highlighter is not None + assert highlighter.code_block_format is not None + + +def test_markdown_highlighter_light_theme(qtbot, app): + """Test markdown highlighter with light theme""" + # Create theme manager with light theme + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + # Create a text document + doc = QTextDocument() + + # Create highlighter with light theme + highlighter = MarkdownHighlighter(doc, themes) + + # Set some markdown text + doc.setPlainText("# Heading\n\nSome **bold** text") + + # The highlighter should work with light theme + assert highlighter is not None + assert highlighter.code_block_format is not None + + +def test_markdown_highlighter_system_dark_theme(qtbot, app, monkeypatch): + """Test markdown highlighter with system dark theme""" + # Create theme manager with system theme + themes = ThemeManager(app, ThemeConfig(theme=Theme.SYSTEM)) + + # Mock the system to be dark + monkeypatch.setattr(themes, "_is_system_dark", True) + + # Create a text document + doc = QTextDocument() + + # Create highlighter + highlighter = MarkdownHighlighter(doc, themes) + + # Set some markdown text + doc.setPlainText("# Dark Theme Heading\n\n**Bold text**") + + # The highlighter should use dark theme colors + assert highlighter is not None + + +def test_markdown_highlighter_with_headings(qtbot, app): + """Test highlighting various heading levels""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +# H1 Heading +## H2 Heading +### H3 Heading +#### H4 Heading +##### H5 Heading +###### H6 Heading +""" + doc.setPlainText(markdown) + + # Should highlight all headings + assert highlighter.h1_format is not None + assert highlighter.h2_format is not None + + +def test_markdown_highlighter_with_emphasis(qtbot, app): + """Test highlighting bold and italic""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +**Bold text** +*Italic text* +***Bold and italic*** +__Also bold__ +_Also italic_ +""" + doc.setPlainText(markdown) + + # Should have emphasis formats + assert highlighter is not None + + +def test_markdown_highlighter_with_code(qtbot, app): + """Test highlighting inline code and code blocks""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +Inline `code` here. + +```python +def hello(): + print("world") +``` + +More text. +""" + doc.setPlainText(markdown) + + # Should highlight code + assert highlighter.code_block_format is not None + + +def test_markdown_highlighter_with_links(qtbot, app): + """Test highlighting links""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +[Link text](https://example.com) + +""" + doc.setPlainText(markdown) + + # Should have link format + assert highlighter is not None + + +def test_markdown_highlighter_with_lists(qtbot, app): + """Test highlighting lists""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +- Unordered item 1 +- Unordered item 2 + +1. Ordered item 1 +2. Ordered item 2 + +- [ ] Unchecked task +- [x] Checked task +""" + doc.setPlainText(markdown) + + # Should highlight lists + assert highlighter is not None + + +def test_markdown_highlighter_with_blockquotes(qtbot, app): + """Test highlighting blockquotes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = """ +> This is a quote +> With multiple lines +""" + doc.setPlainText(markdown) + + # Should highlight quotes + assert highlighter is not None + + +def test_markdown_highlighter_theme_change(qtbot, app): + """Test changing theme after creation""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + doc = QTextDocument() + highlighter = MarkdownHighlighter(doc, themes) + + markdown = "# Heading\n\n**Bold**" + doc.setPlainText(markdown) + + # Change to dark theme + themes.apply(Theme.DARK) + qtbot.wait(50) + + # Highlighter should update + # We can't directly test the visual change, but verify it doesn't crash + assert highlighter is not None + + +def test_auto_pair_skip_closing_bracket(editor, qtbot): + """Test skipping over closing brackets when auto-pairing.""" + # Insert opening bracket + editor.insertPlainText("(") + + # Type closing bracket - should skip over the auto-inserted one + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_ParenRight, Qt.NoModifier, ")") + editor.keyPressEvent(event) + + # Should have only one pair of brackets + text = editor.toPlainText() + assert text.count("(") == 1 + assert text.count(")") == 1 + + +def test_apply_heading(editor, qtbot): + """Test applying heading to text.""" + # Insert some text + editor.insertPlainText("Heading Text") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.StartOfLine) + editor.setTextCursor(cursor) + + # Apply heading - size >= 24 creates level 1 heading + editor.apply_heading(24) + + text = editor.toPlainText() + assert text.startswith("#") + + +def test_handle_return_in_code_block(editor, qtbot): + """Test pressing return inside a code block.""" + # Create a code block + editor.insertPlainText("```python\nprint('hello')") + + # Place cursor at end + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press return - should maintain indentation + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(event) + + # Should have added a new line + text = editor.toPlainText() + assert text.count("\n") >= 2 + + +def test_handle_return_in_list_empty_item(editor, qtbot): + """Test pressing return in an empty list item.""" + # Create list with empty item + editor.insertPlainText("- item\n- ") + + # Place cursor at end of empty item + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press return - should end the list + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(event) + + text = editor.toPlainText() + # Should have processed the empty list marker + lines = text.split("\n") + assert len(lines) >= 2 + + +def test_handle_backspace_in_empty_list_item(editor, qtbot): + """Test pressing backspace in an empty list item.""" + # Create list with cursor after marker + editor.insertPlainText("- ") + + # Place cursor at end + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press backspace - should remove list marker + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "") + editor.keyPressEvent(event) + + text = editor.toPlainText() + # List marker handling + assert len(text) <= 2 + + +def test_tab_key_handling(editor, qtbot): + """Test tab key handling in editor.""" + # Create a list item + editor.insertPlainText("- item") + + # Place cursor in the item + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press tab + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t") + editor.keyPressEvent(event) + + # Should have processed the tab + text = editor.toPlainText() + assert len(text) >= 6 # At least "- item" plus tab + + +def test_drag_enter_with_urls(editor, qtbot): + """Test drag and drop with URLs.""" + from PySide6.QtGui import QDragEnterEvent + + # Create mime data with URLs + mime_data = QMimeData() + mime_data.setUrls([QUrl("file:///tmp/test.txt")]) + + # Create drag enter event + event = QDragEnterEvent( + editor.rect().center(), Qt.CopyAction, mime_data, Qt.LeftButton, Qt.NoModifier + ) + + # Handle drag enter + editor.dragEnterEvent(event) + + # Should accept the event + assert event.isAccepted() + + +def test_drag_enter_with_text(editor, qtbot): + """Test drag and drop with plain text.""" + from PySide6.QtGui import QDragEnterEvent + + # Create mime data with text + mime_data = QMimeData() + mime_data.setText("dragged text") + + # Create drag enter event + event = QDragEnterEvent( + editor.rect().center(), Qt.CopyAction, mime_data, Qt.LeftButton, Qt.NoModifier + ) + + # Handle drag enter + editor.dragEnterEvent(event) + + # Should accept text drag + assert event.isAccepted() + + +def test_highlighter_dark_mode_code_blocks(app, qtbot, tmp_path): + """Test code block highlighting in dark mode.""" + # Get theme manager and set dark mode + theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + + # Create editor with dark theme + editor = MarkdownEditor(theme_manager) + qtbot.addWidget(editor) + + # Insert code block + editor.setPlainText("```python\nprint('hello')\n```") + + # Force rehighlight + editor.highlighter.rehighlight() + + # Verify no crash - actual color verification is difficult in tests + + +def test_highlighter_code_block_with_language(editor, qtbot): + """Test syntax highlighting inside fenced code blocks with language.""" + # Insert code block with language + editor.setPlainText('```python\ndef hello():\n print("world")\n```') + + # Force rehighlight + editor.highlighter.rehighlight() + + # Verify syntax highlighting was applied (lines 186-193) + # We can't easily verify the exact formatting, but we ensure no crash + + +def test_highlighter_bold_italic_overlap_detection(editor, qtbot): + """Test that bold/italic formatting detects overlaps correctly.""" + # Insert text with overlapping bold and triple-asterisk + editor.setPlainText("***bold and italic***") + + # Force rehighlight + editor.highlighter.rehighlight() + + # The overlap detection (lines 252, 264) should prevent issues + + +def test_highlighter_italic_edge_cases(editor, qtbot): + """Test italic formatting edge cases.""" + # Test edge case: avoiding stealing markers that are part of double + # This tests lines 267-270 + editor.setPlainText("**not italic* text**") + + # Force rehighlight + editor.highlighter.rehighlight() + + # Test another edge case + editor.setPlainText("*italic but next to double**") + editor.highlighter.rehighlight() + + +def test_highlighter_multiple_markdown_elements(editor, qtbot): + """Test highlighting document with multiple markdown elements.""" + # Complex document with various elements + text = """# Heading 1 +## Heading 2 + +**bold text** and *italic text* + +```python +def test(): + return True +``` + +- list item +- [ ] task item + +[link](http://example.com) +""" + + editor.setPlainText(text) + editor.highlighter.rehighlight() + + # Verify no crashes with complex formatting + + +def test_highlighter_inline_code_vs_fence(editor, qtbot): + """Test that inline code and fenced blocks are distinguished.""" + text = """Inline `code` here + +``` +fenced block +``` +""" + + editor.setPlainText(text) + editor.highlighter.rehighlight() diff --git a/tests/test_misc.py b/tests/test_misc.py deleted file mode 100644 index 20a3b1c..0000000 --- a/tests/test_misc.py +++ /dev/null @@ -1,113 +0,0 @@ -from PySide6.QtWidgets import QApplication, QMessageBox -from bouquin.main_window import MainWindow -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.db import DBConfig - - -def _themes_light(): - app = QApplication.instance() - return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - - -def _themes_dark(): - app = QApplication.instance() - return ThemeManager(app, ThemeConfig(theme=Theme.DARK)) - - -class FakeDBErr: - def __init__(self, cfg): - pass - - def connect(self): - raise Exception("file is not a database") - - -class FakeDBOk: - def __init__(self, cfg): - pass - - def connect(self): - return True - - def save_new_version(self, date, text, note): - raise RuntimeError("nope") - - def get_entry(self, date): - return "

hi

" - - def get_entries_days(self): - return [] - - -def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path): - # Config with a key so __init__ calls _try_connect immediately - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr) - msgs = {} - monkeypatch.setattr( - QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m)) - ) - w = MainWindow(_themes_light()) # auto-calls _try_connect - qtbot.addWidget(w) - assert "incorrect" in msgs.get("m", "").lower() - - -def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_dark()) - qtbot.addWidget(w) - w._apply_link_css() - css = w.editor.document().defaultStyleSheet() - assert "a {" in css - - -def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_light()) - qtbot.addWidget(w) - called = {} - - class FakeSettings: - def value(self, key, default=None, type=None): - if key == "main/geometry": - return None - if key == "main/windowState": - return None - if key == "main/maximized": - return False - return default - - w.settings = FakeSettings() - monkeypatch.setattr( - w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True) - ) - w._restore_window_position() - assert called.get("x") is True - - -def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_light()) - qtbot.addWidget(w) - captured = {} - monkeypatch.setattr( - w.editor, "insert_images", lambda paths: captured.setdefault("p", paths) - ) - # Simulate file dialog returning paths - monkeypatch.setattr( - "bouquin.main_window.QFileDialog.getOpenFileNames", - staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")), - ) - w._on_insert_image() - assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"] diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py new file mode 100644 index 0000000..9d34a4f --- /dev/null +++ b/tests/test_pomodoro_timer.py @@ -0,0 +1,354 @@ +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" diff --git a/tests/test_reminders.py b/tests/test_reminders.py new file mode 100644 index 0000000..c003d86 --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,801 @@ +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" diff --git a/tests/test_save_dialog.py b/tests/test_save_dialog.py new file mode 100644 index 0000000..255740e --- /dev/null +++ b/tests/test_save_dialog.py @@ -0,0 +1,8 @@ +from bouquin.save_dialog import SaveDialog + + +def test_save_dialog_note_text(qtbot): + dlg = SaveDialog() + qtbot.addWidget(dlg) + dlg.show() + assert dlg.note_text() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..6f3ab23 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,100 @@ +from bouquin.search import Search +from PySide6.QtWidgets import QListWidgetItem + + +def test_search_widget_populates_results(qtbot, fresh_db): + fresh_db.save_new_version("2024-01-01", "alpha bravo", "seed") + fresh_db.save_new_version("2024-01-02", "bravo charlie", "seed") + fresh_db.save_new_version("2024-01-03", "delta alpha bravo", "seed") + + s = Search(fresh_db) + qtbot.addWidget(s) + s.show() + + emitted = [] + s.resultDatesChanged.connect(lambda ds: emitted.append(tuple(ds))) + s.search.setText("alpha") + qtbot.wait(50) + assert s.results.count() >= 2 + assert emitted and {"2024-01-01", "2024-01-03"}.issubset(set(emitted[-1])) + + 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 "Alpha" in frag or "alpha" in frag + assert "delta" 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 diff --git a/tests/test_search_edgecase.py b/tests/test_search_edgecase.py deleted file mode 100644 index 712f7e3..0000000 --- a/tests/test_search_edgecase.py +++ /dev/null @@ -1,15 +0,0 @@ -from bouquin.search import Search as SearchWidget - - -class DummyDB: - def search_entries(self, q): - return [] - - -def test_make_html_snippet_no_match_triggers_start_window(qtbot): - w = SearchWidget(db=DummyDB()) - qtbot.addWidget(w) - html = "

" + ("x" * 300) + "

" # long text, no token present - frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80) - assert frag != "" - assert left is False and right is True diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py deleted file mode 100644 index b3a6751..0000000 --- a/tests/test_search_edges.py +++ /dev/null @@ -1,70 +0,0 @@ -from PySide6.QtWidgets import QApplication -import pytest - -from bouquin.db import DBConfig, DBManager -from bouquin.search import Search - - -@pytest.fixture(scope="module") -def app(): - # Ensure a single QApplication exists - a = QApplication.instance() - if a is None: - a = QApplication([]) - yield a - - -@pytest.fixture -def fresh_db(tmp_path): - cfg = DBConfig(path=tmp_path / "test.db", key="testkey") - db = DBManager(cfg) - assert db.connect() is True - # Seed a couple of entries - db.save_new_version("2025-01-01", "

Hello world first day

") - db.save_new_version( - "2025-01-02", "

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

" - ) - db.save_new_version( - "2025-01-03", - "

Long content begins " - + ("x" * 200) - + " middle token here " - + ("y" * 200) - + " ends.

", - ) - return db - - -def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot): - # Close the DB to provoke an exception inside Search._search - fresh_db.close() - w = Search(fresh_db) - w.show() - qtbot.addWidget(w) - - # Typing should not raise; exception path returns empty results - w._search("anything") - assert w.results.isHidden() # remains hidden because there are no rows - - -def test_make_html_snippet_ellipses_both_sides(app, fresh_db): - w = Search(fresh_db) - # Choose a query so that the first match sits well inside a long string, - # forcing both left and right ellipses. - html = fresh_db.get_entry("2025-01-03") - snippet, left_ell, right_ell = w._make_html_snippet(html, "middle") - assert snippet # non-empty - assert left_ell is True - assert right_ell is True - - -def test_search_results_middle(app, fresh_db, qtbot): - w = Search(fresh_db) - w.show() - qtbot.addWidget(w) - # Choose a query so that the first match sits well inside a long string, - # forcing both left and right ellipses. - assert fresh_db.connect() - - w._search("middle") - assert w.results.isVisible() diff --git a/tests/test_search_history.py b/tests/test_search_history.py deleted file mode 100644 index 10ff25c..0000000 --- a/tests/test_search_history.py +++ /dev/null @@ -1,110 +0,0 @@ -from PySide6.QtCore import Qt -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QListWidget, QWidget, QAbstractButton - -from tests.qt_helpers import ( - trigger_menu_action, - wait_for_widget, - find_line_edit_by_placeholder, -) - - -def test_search_and_open_date(open_window, qtbot): - win = open_window - - win.editor.setPlainText("lorem ipsum target") - win._save_current(explicit=True) - base = win.calendar.selectedDate() - d2 = base.addDays(7) - win.calendar.setSelectedDate(d2) - win.editor.setPlainText("target appears here, too") - win._save_current(explicit=True) - - search_box = find_line_edit_by_placeholder(win, "search") - assert search_box is not None, "Search input not found" - search_box.setText("target") - QTest.qWait(150) - - results = getattr(getattr(win, "search", None), "results", None) - if isinstance(results, QListWidget) and results.count() > 0: - # Click until we land on d2 - landed = False - for i in range(results.count()): - item = results.item(i) - rect = results.visualItemRect(item) - QTest.mouseDClick(results.viewport(), Qt.LeftButton, pos=rect.center()) - qtbot.wait(120) - if win.calendar.selectedDate() == d2: - landed = True - break - assert landed, "Search results did not navigate to the expected date" - else: - assert "target" in win.editor.toPlainText().lower() - - -def test_history_dialog_revert(open_window, qtbot): - win = open_window - - # Create two versions on the current day - win.editor.setPlainText("v1 text") - win._save_current(explicit=True) - win.editor.setPlainText("v2 text") - win._save_current(explicit=True) - - # Open the History UI (label varies) - try: - trigger_menu_action(win, "View History") - except AssertionError: - trigger_menu_action(win, "History") - - # Find ANY top-level window that looks like the History dialog - def _is_history(w: QWidget): - if not w.isWindow() or not w.isVisible(): - return False - title = (w.windowTitle() or "").lower() - return "history" in title or bool(w.findChildren(QListWidget)) - - hist = wait_for_widget(QWidget, predicate=_is_history, timeout_ms=15000) - - # Wait for population and pick the list with the most items - chosen = None - for _ in range(120): # up to ~3s - lists = hist.findChildren(QListWidget) - if lists: - chosen = max(lists, key=lambda lw: lw.count()) - if chosen.count() >= 2: - break - QTest.qWait(25) - - assert ( - chosen is not None and chosen.count() >= 2 - ), "History list never populated with 2+ versions" - - # Click the older version row so the Revert button enables - idx = 1 # row 0 is most-recent "v2 text", row 1 is "v1 text" - rect = chosen.visualItemRect(chosen.item(idx)) - QTest.mouseClick(chosen.viewport(), Qt.LeftButton, pos=rect.center()) - QTest.qWait(100) - - # Find any enabled button whose text/tooltip/objectName contains 'revert' - revert_btn = None - for _ in range(120): # wait until it enables - for btn in hist.findChildren(QAbstractButton): - meta = " ".join( - [btn.text() or "", btn.toolTip() or "", btn.objectName() or ""] - ).lower() - if "revert" in meta: - revert_btn = btn - break - if revert_btn and revert_btn.isEnabled(): - break - QTest.qWait(25) - - assert ( - revert_btn is not None and revert_btn.isEnabled() - ), "Revert button not found/enabled" - QTest.mouseClick(revert_btn, Qt.LeftButton) - - # AutoResponder will accept confirm/success boxes - QTest.qWait(150) - assert "v1 text" in win.editor.toPlainText() diff --git a/tests/test_search_unit.py b/tests/test_search_unit.py deleted file mode 100644 index 13c1ef9..0000000 --- a/tests/test_search_unit.py +++ /dev/null @@ -1,57 +0,0 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QListWidgetItem - -# The widget class is named `Search` in bouquin.search -from bouquin.search import Search as SearchWidget - - -class FakeDB: - def __init__(self, rows): - self.rows = rows - - def search_entries(self, q): - return list(self.rows) - - -def test_search_empty_clears_and_hides(qtbot): - w = SearchWidget(db=FakeDB([])) - qtbot.addWidget(w) - w.show() - qtbot.waitExposed(w) - dates = [] - w.resultDatesChanged.connect(lambda ds: dates.extend(ds)) - w._search(" ") - assert w.results.isHidden() - assert dates == [] - - -def test_populate_empty_hides(qtbot): - w = SearchWidget(db=FakeDB([])) - qtbot.addWidget(w) - w._populate_results("x", []) - assert w.results.isHidden() - - -def test_open_selected_emits_when_present(qtbot): - w = SearchWidget(db=FakeDB([])) - qtbot.addWidget(w) - got = {} - w.openDateRequested.connect(lambda d: got.setdefault("d", d)) - it = QListWidgetItem("x") - it.setData(Qt.ItemDataRole.UserRole, "") - w._open_selected(it) - assert "d" not in got - it.setData(Qt.ItemDataRole.UserRole, "2025-01-02") - w._open_selected(it) - assert got["d"] == "2025-01-02" - - -def test_make_html_snippet_edge_cases(qtbot): - w = SearchWidget(db=FakeDB([])) - qtbot.addWidget(w) - # Empty HTML -> empty fragment, no ellipses - frag, l, r = w._make_html_snippet("", "hello") - assert frag == "" and not l and not r - # Small doc around token -> should not show ellipses - frag, l, r = w._make_html_snippet("

Hello world

", "world") - assert "world" in frag or "world" in frag diff --git a/tests/test_search_windows.py b/tests/test_search_windows.py deleted file mode 100644 index 5770e73..0000000 --- a/tests/test_search_windows.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -from bouquin.search import Search - - -@pytest.fixture -def search_widget(qapp): - # We don't need a real DB for snippet generation – pass None - return Search(db=None) - - -def test_make_html_snippet_empty(search_widget: Search): - html = "" - frag, has_prev, has_next = search_widget._make_html_snippet( - html, "", radius=10, maxlen=20 - ) - assert frag == "" and has_prev is False and has_next is False - - -def test_make_html_snippet_phrase_preferred(search_widget: Search): - html = "

Alpha beta gamma delta

" - frag, has_prev, has_next = search_widget._make_html_snippet( - html, "beta gamma", radius=1, maxlen=10 - ) - # We expect a window that includes the phrase and has previous text - assert "beta" in frag and "gamma" in frag - assert has_prev is True - - -def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search): - html = "

One two three four five six seven eight nine ten eleven twelve

" - # Use tokens such that the phrase doesn't exist, but individual tokens do - frag, has_prev, has_next = search_widget._make_html_snippet( - html, "eleven two", radius=3, maxlen=20 - ) - assert "two" in frag - # The snippet should be a slice within the text (has more following content) - assert has_next is True diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..f272ab2 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,71 @@ +from bouquin.settings import ( + get_settings, + load_db_config, + save_db_config, +) +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_load_and_save_db_config_roundtrip(app, tmp_path): + _clear_db_settings() + + cfg = DBConfig( + path=tmp_path / "notes.db", + key="abc123", + idle_minutes=7, + theme="dark", + move_todos=True, + tags=True, + time_log=True, + reminders=True, + locale="en", + font_size=11, + ) + save_db_config(cfg) + + loaded = load_db_config() + assert loaded.path == cfg.path + assert loaded.key == cfg.key + 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) == "" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index b1962c7..ad53951 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,296 +1,412 @@ -from pathlib import Path - -from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget - -from bouquin.db import DBConfig +from bouquin.db import DBManager, DBConfig +from bouquin.key_prompt import KeyPrompt +import bouquin.settings_dialog as sd from bouquin.settings_dialog import SettingsDialog -from bouquin.theme import Theme +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 -class _ThemeSpy: - def __init__(self): - self.calls = [] +def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): + # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...)) + app = QApplication.instance() + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent) + qtbot.addWidget(dlg) + dlg.show() - def set(self, t): - self.calls.append(t) + 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(): + for w in QApplication.topLevelWidgets(): + if isinstance(w, QMessageBox): + w.accept() + + QTimer.singleShot(0, _auto_accept_msgbox) + dlg._compact_btn_clicked() + qtbot.wait(50) + + dlg._save() + cfg = dlg.config + 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") -class _Parent(QWidget): - def __init__(self): - super().__init__() - self.themes = _ThemeSpy() +def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent) + qtbot.addWidget(dlg) + dlg.show() + + # Ensure a clean starting state (suite may leave settings toggled on) + dlg.save_key_btn.setChecked(False) + dlg.key = "" + + # Robust popup pump so we never miss late dialogs + def _pump(): + for w in QApplication.topLevelWidgets(): + if isinstance(w, KeyPrompt): + w.key_entry.setText("supersecret") + w.accept() + elif isinstance(w, QMessageBox): + w.accept() + + timer = QTimer() + timer.setInterval(10) + timer.timeout.connect(_pump) + timer.start() + try: + dlg.save_key_btn.setChecked(True) + qtbot.waitUntil(lambda: dlg.key == "supersecret", timeout=1000) + assert dlg.save_key_btn.isChecked() + + dlg.save_key_btn.setChecked(False) + qtbot.waitUntil(lambda: dlg.key == "", timeout=1000) + assert dlg.key == "" + finally: + timer.stop() -class FakeDB: - def __init__(self): - self.rekey_called_with = None - self.compact_called = False - self.fail_compact = False +def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): + cfg = DBConfig( + path=tmp_path / "iso.db", + key="oldkey", + idle_minutes=0, + theme="light", + move_todos=True, + ) + db = DBManager(cfg) + assert db.connect() + db.save_new_version("2000-01-01", "seed", "seed") - def rekey(self, key: str): - self.rekey_called_with = key - - def compact(self): - if self.fail_compact: - raise RuntimeError("boom") - self.compact_called = True - - -class AcceptingPrompt: - def __init__(self, parent=None, title="", message=""): - self._key = "" - self._accepted = True - - def set_key(self, k: str): - self._key = k - return self - - def exec(self): - return QDialog.Accepted if self._accepted else QDialog.Rejected - - def key(self): - return self._key - - -class RejectingPrompt(AcceptingPrompt): - def __init__(self, *a, **k): - super().__init__() - self._accepted = False - - -def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): - db = FakeDB() - cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15) - - saved = {} - - def fake_save(cfg2): - saved["cfg"] = cfg2 - - monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save) - - # Drive the "remember key" checkbox via the prompt (no pre-set key) - p = AcceptingPrompt().set_key("sekrit") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - - # Provide a lightweight parent that mimics MainWindow’s `themes` API - class _ThemeSpy: - def __init__(self): - self.calls = [] - - def set(self, theme): - self.calls.append(theme) - - class _Parent(QWidget): - def __init__(self): - super().__init__() - self.themes = _ThemeSpy() - - parent = _Parent() - qtbot.addWidget(parent) + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) dlg = SettingsDialog(cfg, db, parent=parent) qtbot.addWidget(dlg) dlg.show() - qtbot.waitExposed(dlg) - # Change fields - new_path = tmp_path / "new.sqlite" - dlg.path_edit.setText(str(new_path)) - dlg.idle_spin.setValue(0) + keys = ["one", "two"] - # User toggles "Remember key" -> stores prompted key - dlg.save_key_btn.setChecked(True) + def _pump_popups(): + for w in QApplication.topLevelWidgets(): + if isinstance(w, KeyPrompt): + w.key_entry.setText(keys.pop(0) if keys else "zzz") + w.accept() + elif isinstance(w, QMessageBox): + w.accept() - dlg._save() - - out = saved["cfg"] - assert out.path == new_path - assert out.idle_minutes == 0 - assert out.key == "sekrit" - assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM + timer = QTimer() + timer.setInterval(10) + timer.timeout.connect(_pump_popups) + timer.start() + try: + dlg._change_key() + finally: + timer.stop() + db.close() + db2 = DBManager(cfg) + assert db2.connect() + db2.close() -def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot): - # When toggled on with no key yet, it prompts; cancelling should revert the check - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt) - - dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - assert dlg.key == "" - dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects - assert dlg.save_key_btn.isChecked() is False - assert dlg.key == "" - - -def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot): - # Toggling on with an accepting prompt should store the typed key - p = AcceptingPrompt().set_key("remember-me") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - - dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg.save_key_btn.click() - assert dlg.save_key_btn.isChecked() is True - assert dlg.key == "remember-me" - - -def test_change_key_success(monkeypatch, qtbot): - # Two prompts returning the same non-empty key -> rekey() and info message - p1 = AcceptingPrompt().set_key("newkey") - p2 = AcceptingPrompt().set_key("newkey") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) - - shown = {"info": 0} - monkeypatch.setattr( - QMessageBox, - "information", - lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), +def test_change_key_success(qtbot, tmp_path, app): + cfg = DBConfig( + path=tmp_path / "iso2.db", + key="oldkey", + idle_minutes=0, + theme="light", + move_todos=True, ) + db = DBManager(cfg) + assert db.connect() + db.save_new_version("2001-01-01", "seed", "seed") - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + dlg = SettingsDialog(cfg, db, parent=parent) qtbot.addWidget(dlg) dlg.show() - qtbot.waitExposed(dlg) - dlg._change_key() + keys = ["newkey", "newkey"] - assert db.rekey_called_with == "newkey" - assert shown["info"] >= 1 - assert dlg.key == "newkey" + def _pump(): + for w in QApplication.topLevelWidgets(): + if isinstance(w, KeyPrompt): + w.key_entry.setText(keys.pop(0) if keys else "newkey") + w.accept() + elif isinstance(w, QMessageBox): + w.accept() + + timer = QTimer() + timer.setInterval(10) + timer.timeout.connect(_pump) + timer.start() + try: + dlg._change_key() + finally: + timer.stop() + qtbot.wait(50) + + db.close() + cfg.key = "newkey" + db2 = DBManager(cfg) + assert db2.connect() + assert "seed" in db2.get_entry("2001-01-01") + db2.close() -def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot): - p1 = AcceptingPrompt().set_key("a") - p2 = AcceptingPrompt().set_key("b") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) - - called = {"warn": 0} - monkeypatch.setattr( - QMessageBox, - "warning", - lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), - ) - - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) +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() - qtbot.waitExposed(dlg) - dlg._change_key() + # Monkeypatch db.compact to raise + def boom(): + raise RuntimeError("nope") - assert db.rekey_called_with is None - assert called["warn"] >= 1 + dlg._db.compact = boom # type: ignore + called = {"critical": False, "title": None, "text": None} -def test_change_key_empty_shows_warning(monkeypatch, qtbot): - p1 = AcceptingPrompt().set_key("") - p2 = AcceptingPrompt().set_key("") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) + class DummyMB: + @staticmethod + def information(*args, **kwargs): + return 0 - called = {"warn": 0} - monkeypatch.setattr( - QMessageBox, - "warning", - lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), - ) + @staticmethod + def critical(parent, title, text, *rest): + called["critical"] = True + called["title"] = title + called["text"] = str(text) + return 0 - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._change_key() - - assert db.rekey_called_with is None - assert called["warn"] >= 1 - - -def test_browse_sets_path(monkeypatch, qtbot, tmp_path): - def fake_get_save_file_name(*a, **k): - return (str(tmp_path / "picked.sqlite"), "") - - monkeypatch.setattr( - QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name) - ) - - dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._browse() - assert dlg.path_edit.text().endswith("picked.sqlite") - - -def test_compact_success_and_failure(monkeypatch, qtbot): - shown = {"info": 0, "crit": 0} - monkeypatch.setattr( - QMessageBox, - "information", - lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), - ) - monkeypatch.setattr( - QMessageBox, - "critical", - lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1), - ) - - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) + # 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 db.compact_called is True - assert shown["info"] >= 1 - # Failure path - db2 = FakeDB() - db2.fail_compact = True - dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2) - qtbot.addWidget(dlg2) - dlg2.show() - qtbot.waitExposed(dlg2) - - dlg2._compact_btn_clicked() - assert shown["crit"] >= 1 + assert called["critical"] + assert called["title"] + assert called["text"] -def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot): - p = AcceptingPrompt().set_key("already") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) +class _Host(QWidget): + def __init__(self, themes): + super().__init__() + self.themes = themes - dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB()) + +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) - dlg.show() - qtbot.waitExposed(dlg) - - dlg.save_key_btn.setChecked(True) - # We should reach here with the original key preserved. - assert dlg.key == "already" + # With fresh settings (system), the 'system' radio should be selected + assert dlg.theme_system.isChecked() -def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path): - parent = _Parent() - qtbot.addWidget(parent) - cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5) - dlg = SettingsDialog(cfg, FakeDB(), parent=parent) +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) - dlg.save_key_btn.setChecked(False) - # Trigger save + # 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() - assert dlg.config.key == "" # cleared - assert parent.themes.calls # applied some theme + + +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() diff --git a/tests/test_settings_dialog_cancel_paths.py b/tests/test_settings_dialog_cancel_paths.py deleted file mode 100644 index bd86325..0000000 --- a/tests/test_settings_dialog_cancel_paths.py +++ /dev/null @@ -1,111 +0,0 @@ -import pytest -from PySide6.QtWidgets import QApplication, QDialog, QWidget - -from bouquin.db import DBConfig, DBManager -from bouquin.settings_dialog import SettingsDialog -from bouquin.settings import APP_NAME, APP_ORG -from bouquin.key_prompt import KeyPrompt -from bouquin.theme import Theme, ThemeManager, ThemeConfig - - -@pytest.fixture(scope="module") -def app(): - a = QApplication.instance() - if a is None: - a = QApplication([]) - a.setApplicationName(APP_NAME) - a.setOrganizationName(APP_ORG) - return a - - -@pytest.fixture -def db(tmp_path): - cfg = DBConfig(path=tmp_path / "s.db", key="abc") - m = DBManager(cfg) - assert m.connect() - return m - - -def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot): - # Dark preselection - parent = _ParentWithThemes(app) - qtbot.addWidget(parent) - dlg = SettingsDialog(db.cfg, db, parent=parent) - qtbot.addWidget(dlg) - dlg.theme_dark.setChecked(True) - dlg._save() - assert dlg.config.theme == Theme.DARK.value - - # Light preselection - parent2 = _ParentWithThemes(app) - qtbot.addWidget(parent2) - dlg2 = SettingsDialog(db.cfg, db, parent=parent2) - qtbot.addWidget(dlg2) - dlg2.theme_light.setChecked(True) - dlg2._save() - assert dlg2.config.theme == Theme.LIGHT.value - - -def test_change_key_cancel_branches(app, db, monkeypatch, qtbot): - parent = _ParentWithThemes(app) - qtbot.addWidget(parent) - dlg = SettingsDialog(db.cfg, db, parent=parent) - qtbot.addWidget(dlg) - - # First prompt cancelled -> early return - monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected) - dlg._change_key() # should just return without altering key - assert dlg.key == "" - - # First OK, second cancelled -> early return at the second branch - state = {"calls": 0} - - def _exec(self): - state["calls"] += 1 - return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected - - monkeypatch.setattr(KeyPrompt, "exec", _exec) - # Also monkeypatch to control key() values - monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret") - dlg._change_key() - # Because the second prompt was rejected, key should remain unchanged - assert dlg.key == "" - - -def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot): - parent = _ParentWithThemes(app) - qtbot.addWidget(parent) - dlg = SettingsDialog(db.cfg, db, parent=parent) - qtbot.addWidget(dlg) - qtbot.addWidget(dlg) - - # Simulate user checking the box, but cancelling the prompt -> code unchecks it again - monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected) - dlg.save_key_btn.setChecked(True) - # The slot toggled should run and revert it to unchecked - assert dlg.save_key_btn.isChecked() is False - - -def test_change_key_exception_path(app, db, monkeypatch, qtbot): - parent = _ParentWithThemes(app) - qtbot.addWidget(parent) - dlg = SettingsDialog(db.cfg, db, parent=parent) - qtbot.addWidget(dlg) - - # Accept both prompts and supply a key - monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted) - monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom") - - # Force DB rekey to raise to exercise the except-branch - monkeypatch.setattr( - db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail")) - ) - - # Should not raise; error is handled internally - dlg._change_key() - - -class _ParentWithThemes(QWidget): - def __init__(self, app): - super().__init__() - self.themes = ThemeManager(app, ThemeConfig()) diff --git a/tests/test_settings_module.py b/tests/test_settings_module.py deleted file mode 100644 index 24a9aac..0000000 --- a/tests/test_settings_module.py +++ /dev/null @@ -1,28 +0,0 @@ -from bouquin.db import DBConfig -import bouquin.settings as settings - - -class FakeSettings: - def __init__(self): - self.store = {} - - def value(self, key, default=None, type=None): - return self.store.get(key, default) - - def setValue(self, key, value): - self.store[key] = value - - -def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path): - fake = FakeSettings() - monkeypatch.setattr(settings, "get_settings", lambda: fake) - - cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark") - settings.save_db_config(cfg) - - # Now read back into a new DBConfig - cfg2 = settings.load_db_config() - assert cfg2.path == cfg.path - assert cfg2.key == "k" - assert cfg2.idle_minutes == "7" - assert cfg2.theme == "dark" diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py new file mode 100644 index 0000000..8ff73b1 --- /dev/null +++ b/tests/test_statistics_dialog.py @@ -0,0 +1,636 @@ +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 diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 0000000..ec2c445 --- /dev/null +++ b/tests/test_strings.py @@ -0,0 +1,17 @@ +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 diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..fe73828 --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,195 @@ +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" diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..8564c6b --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,2319 @@ +import pytest + +from PySide6.QtCore import Qt, QPoint, QEvent, QDate +from PySide6.QtGui import QMouseEvent, QColor +from PySide6.QtWidgets import ( + QApplication, + QMessageBox, + QInputDialog, + QColorDialog, + QDialog, +) +from bouquin.db import DBManager +from bouquin.strings import load_strings +from bouquin.tags_widget import PageTagsWidget, TagChip +from bouquin.tag_browser import TagBrowserDialog +from bouquin.flow_layout import FlowLayout +from sqlcipher3.dbapi2 import IntegrityError + +import bouquin.strings as strings + + +# ============================================================================ +# DB Layer Tag Tests +# ============================================================================ + + +def test_set_tags_for_page_creates_tags(fresh_db): + """Test that setting tags for a page creates the tags in the database""" + date_iso = "2024-01-15" + tags = ["work", "important", "meeting"] + + fresh_db.set_tags_for_page(date_iso, tags) + + # Verify tags were created + all_tags = fresh_db.list_tags() + tag_names = [name for _, name, _ in all_tags] + + assert "work" in tag_names + assert "important" in tag_names + assert "meeting" in tag_names + + +def test_get_tags_for_page(fresh_db): + """Test retrieving tags for a specific page""" + date_iso = "2024-01-15" + tags = ["work", "important"] + + fresh_db.set_tags_for_page(date_iso, tags) + retrieved_tags = fresh_db.get_tags_for_page(date_iso) + + assert len(retrieved_tags) == 2 + tag_names = [name for _, name, _ in retrieved_tags] + assert "work" in tag_names + assert "important" in tag_names + + +def test_tags_have_colors(fresh_db): + """Test that created tags have default colors assigned""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["test"]) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + tag_id, name, color = tags[0] + + assert color.startswith("#") + assert len(color) in (4, 7) # #RGB or #RRGGBB + + +def test_set_tags_replaces_existing(fresh_db): + """Test that setting tags replaces the existing tag set""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["old1", "old2"]) + fresh_db.set_tags_for_page(date_iso, ["new1", "new2"]) + + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "new1" in tag_names + assert "new2" in tag_names + assert "old1" not in tag_names + assert "old2" not in tag_names + + +def test_set_tags_empty_clears_tags(fresh_db): + """Test that setting empty tag list clears all tags for page""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + fresh_db.set_tags_for_page(date_iso, []) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 0 + + +def test_tags_case_insensitive_deduplication(fresh_db): + """Test that tags are deduplicated case-insensitively""" + date_iso = "2024-01-15" + + # Try to set tags with different cases + fresh_db.set_tags_for_page(date_iso, ["Work", "work", "WORK"]) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_tags_case_insensitive_reuse(fresh_db): + """Test that existing tags are reused regardless of case""" + date1 = "2024-01-15" + date2 = "2024-01-16" + + # Create tag with lowercase + fresh_db.set_tags_for_page(date1, ["work"]) + + # Try to add same tag with different case on different page + fresh_db.set_tags_for_page(date2, ["Work"]) + + # Should reuse the existing tag + all_tags = fresh_db.list_tags() + work_tags = [t for t in all_tags if t[1].lower() == "work"] + assert len(work_tags) == 1 # Only one "work" tag should exist + + +def test_tags_whitespace_normalization(fresh_db): + """Test that tags are trimmed and empty strings ignored""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, [" work ", "", " ", "meeting"]) + + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "work" in tag_names + assert "meeting" in tag_names + assert len(tags) == 2 # Empty strings should be filtered + + +def test_list_all_tags(fresh_db): + """Test listing all tags in the database""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.set_tags_for_page("2024-01-16", ["tag2", "tag3"]) + + all_tags = fresh_db.list_tags() + tag_names = [name for _, name, _ in all_tags] + + assert len(all_tags) == 3 + assert "tag1" in tag_names + assert "tag2" in tag_names + assert "tag3" in tag_names + + +def test_add_tag_name_and_color(fresh_db): + """Test adding a tag's name and color""" + fresh_db.add_tag("new123", "#FF0000") + + updated_tags = fresh_db.list_tags() + assert len(updated_tags) == 1 + assert updated_tags[0][1] == "new123" + assert updated_tags[0][2] == "#FF0000" + + +def test_update_tag_name_and_color(fresh_db): + """Test updating a tag's name and color""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["oldname"]) + + tags = fresh_db.list_tags() + tag_id = tags[0][0] + + fresh_db.update_tag(tag_id, "newname", "#FF0000") + + updated_tags = fresh_db.list_tags() + assert len(updated_tags) == 1 + assert updated_tags[0][1] == "newname" + assert updated_tags[0][2] == "#FF0000" + + +def test_update_tag_existing_name_fails(fresh_db): + """Test updating a tag's name to an existing tag fails""" + load_strings("en") + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + tags = fresh_db.list_tags() + tag_id = tags[0][0] + + with pytest.raises(IntegrityError) as excinfo: + fresh_db.update_tag(tag_id, "tag2", "#111111") + assert str(excinfo.value) == "A tag already exists with that name" + + +def test_delete_tag(fresh_db): + """Test deleting a tag removes it globally""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.set_tags_for_page("2024-01-16", ["tag1"]) + + tags = fresh_db.list_tags() + tag1_id = [tid for tid, name, _ in tags if name == "tag1"][0] + + fresh_db.delete_tag(tag1_id) + + # Tag should be removed from all pages + tags_page1 = fresh_db.get_tags_for_page("2024-01-15") + tags_page2 = fresh_db.get_tags_for_page("2024-01-16") + + assert len(tags_page1) == 1 + assert tags_page1[0][1] == "tag2" + assert len(tags_page2) == 0 + + +def test_get_pages_for_tag(fresh_db): + """Test retrieving all pages that have a specific tag""" + fresh_db.save_new_version("2024-01-15", "Content 1", "note") + fresh_db.save_new_version("2024-01-16", "Content 2", "note") + fresh_db.save_new_version("2024-01-17", "Content 3", "note") + + fresh_db.set_tags_for_page("2024-01-15", ["work"]) + fresh_db.set_tags_for_page("2024-01-16", ["work", "meeting"]) + fresh_db.set_tags_for_page("2024-01-17", ["personal"]) + + pages = fresh_db.get_pages_for_tag("work") + dates = [date_iso for date_iso, _ in pages] + + assert "2024-01-15" in dates + assert "2024-01-16" in dates + assert "2024-01-17" not in dates + + +def test_tags_persist_across_reconnect(fresh_db, tmp_db_cfg): + """Test that tags persist when database is closed and reopened""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["persistent", "tag"]) + fresh_db.close() + + # Reopen database + db2 = DBManager(tmp_db_cfg) + assert db2.connect() + + tags = db2.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "persistent" in tag_names + assert "tag" in tag_names + db2.close() + + +# ============================================================================ +# PageTagsWidget Tests +# ============================================================================ + + +def test_page_tags_widget_creation(app, fresh_db): + """Test that PageTagsWidget can be created""" + widget = PageTagsWidget(fresh_db) + assert widget is not None + assert widget._db == fresh_db + + +def test_page_tags_widget_set_current_date(app, fresh_db): + """Test setting the current date on the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["test"]) + widget.set_current_date(date_iso) + + assert widget._current_date == date_iso + + +def test_page_tags_widget_hidden_when_collapsed(app, fresh_db): + """Test that tag chips are hidden when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + + # Body should be hidden when collapsed + assert not widget.body.isVisible() + assert not widget.toggle_btn.isChecked() + + +def test_page_tags_widget_shows_tags_when_expanded(app, fresh_db): + """Test that tags are shown when widget is expanded""" + widget = PageTagsWidget(fresh_db) + widget.show() # Widget needs to be shown for visibility to work + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + + # Expand the widget + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + assert widget.body.isVisible() + assert widget.chip_layout.count() == 2 + + +def test_page_tags_widget_add_tag(app, fresh_db): + """Test adding a tag through the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Simulate adding a tag + widget.add_edit.setText("newtag") + widget._on_add_tag() + + # Verify tag was added + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + assert "newtag" in tag_names + + +def test_page_tags_widget_prevents_duplicates(app, fresh_db): + """Test that duplicate tags are prevented""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["existing"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add the same tag + widget.add_edit.setText("existing") + widget._on_add_tag() + + # Should still only have one tag + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_page_tags_widget_case_insensitive_duplicates(app, fresh_db): + """Test that duplicate checking is case-insensitive""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["Test"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add with different case + widget.add_edit.setText("test") + widget._on_add_tag() + + # Should still only have one tag + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_page_tags_widget_remove_tag(app, fresh_db): + """Test removing a tag through the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Get the first tag's ID + tags = fresh_db.get_tags_for_page(date_iso) + tag_id = tags[0][0] + + # Remove it + widget._remove_tag(tag_id) + + # Verify tag was removed + remaining_tags = fresh_db.get_tags_for_page(date_iso) + assert len(remaining_tags) == 1 + + +def test_page_tags_widget_empty_input_ignored(app, fresh_db): + """Test that empty tag input is ignored""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add empty tag + widget.add_edit.setText("") + widget._on_add_tag() + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 0 + + +def test_page_tags_widget_whitespace_trimmed(app, fresh_db): + """Test that whitespace is trimmed from tags""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Add tag with whitespace + widget.add_edit.setText(" spaced ") + widget._on_add_tag() + + tags = fresh_db.get_tags_for_page(date_iso) + assert tags[0][1] == "spaced" + + +def test_page_tags_widget_autocomplete_setup(app, fresh_db): + """Test that autocomplete is set up with existing tags""" + widget = PageTagsWidget(fresh_db) + + # Create some tags + fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta", "gamma"]) + + # Setup autocomplete + widget._setup_autocomplete() + + completer = widget.add_edit.completer() + assert completer is not None + + # Check that model contains the tags + model = completer.model() + items = [model.index(i, 0).data() for i in range(model.rowCount())] + assert "alpha" in items + assert "beta" in items + assert "gamma" in items + + +def test_page_tags_widget_signal_tag_added(app, fresh_db): + """Test that tagAdded signal is emitted when tag is added""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + + signal_emitted = {"emitted": False} + + def on_tag_added(): + signal_emitted["emitted"] = True + + widget.tagAdded.connect(on_tag_added) + + widget.add_edit.setText("testtag") + widget._on_add_tag() + + assert signal_emitted["emitted"] + + +def test_page_tags_widget_signal_tag_activated(app, fresh_db): + """Test that tagActivated signal is emitted when tag is clicked""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["clickable"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + signal_data = {"tag_name": None} + + def on_tag_activated(name): + signal_data["tag_name"] = name + + widget.tagActivated.connect(on_tag_activated) + widget._on_chip_clicked("clickable") + + assert signal_data["tag_name"] == "clickable" + + +# ============================================================================ +# TagChip Tests +# ============================================================================ + + +def test_tag_chip_creation(app): + """Test that TagChip can be created""" + chip = TagChip(1, "test", "#FF0000") + assert chip is not None + assert chip.tag_id == 1 + + +def test_tag_chip_with_remove_button(app): + """Test TagChip with remove button""" + chip = TagChip(1, "test", "#FF0000", show_remove=True) + + # Find the remove button (should be a QToolButton with text "×") + buttons = chip.findChildren(object) + assert any(hasattr(b, "text") and b.text() == "×" for b in buttons) + + +def test_tag_chip_without_remove_button(app): + """Test TagChip without remove button""" + chip = TagChip(1, "test", "#FF0000", show_remove=False) + + # Should not have remove button + buttons = chip.findChildren(object) + assert not any(hasattr(b, "text") and b.text() == "×" for b in buttons) + + +def test_tag_chip_color_display(app): + """Test that TagChip displays the correct color""" + chip = TagChip(1, "test", "#FF0000") + + # Find the color label + labels = chip.findChildren(object) + color_labels = [ + l + for l in labels + if hasattr(l, "styleSheet") and "background-color" in str(l.styleSheet()) + ] + + assert len(color_labels) > 0 + assert ( + "#FF0000" in color_labels[0].styleSheet() + or "rgb(255, 0, 0)" in color_labels[0].styleSheet() + ) + + +def test_tag_chip_remove_signal(app): + """Test that TagChip emits removeRequested signal""" + chip = TagChip(1, "test", "#FF0000", show_remove=True) + + signal_data = {"tag_id": None} + + def on_remove(tag_id): + signal_data["tag_id"] = tag_id + + chip.removeRequested.connect(on_remove) + chip.removeRequested.emit(1) + + assert signal_data["tag_id"] == 1 + + +def test_tag_chip_clicked_signal(app): + """Test that TagChip emits clicked signal""" + chip = TagChip(1, "test", "#FF0000") + + signal_data = {"tag_name": None} + + def on_clicked(name): + signal_data["tag_name"] = name + + chip.clicked.connect(on_clicked) + chip.clicked.emit("test") + + assert signal_data["tag_name"] == "test" + + +# ============================================================================ +# TagBrowserDialog Tests +# ============================================================================ + + +def test_tag_browser_creation(app, fresh_db): + """Test that TagBrowserDialog can be created""" + dialog = TagBrowserDialog(fresh_db) + assert dialog is not None + + +def test_tag_browser_displays_tags(app, fresh_db): + """Test that TagBrowserDialog displays tags in tree""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.save_new_version("2024-01-15", "Content", "note") + + dialog = TagBrowserDialog(fresh_db) + + # Tree should have top-level items for each tag + assert dialog.tree.topLevelItemCount() == 2 + + +def test_tag_browser_tag_with_pages(app, fresh_db): + """Test that TagBrowserDialog shows pages under tags""" + fresh_db.save_new_version("2024-01-15", "Content 1", "note") + fresh_db.save_new_version("2024-01-16", "Content 2", "note") + fresh_db.set_tags_for_page("2024-01-15", ["work"]) + fresh_db.set_tags_for_page("2024-01-16", ["work"]) + + dialog = TagBrowserDialog(fresh_db) + + # Find the "work" tag item + root = dialog.tree.topLevelItem(0) + + # Should have 2 child items (the dates) + assert root.childCount() == 2 + + +def test_tag_browser_focus_tag(app, fresh_db): + """Test that TagBrowserDialog can focus on a specific tag""" + fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag="beta") + + # The focused tag should be expanded and selected + current_item = dialog.tree.currentItem() + assert current_item is not None + + +def test_tag_browser_color_display(app, fresh_db): + """Test that tags display their colors in the browser""" + fresh_db.set_tags_for_page("2024-01-15", ["colorful"]) + + dialog = TagBrowserDialog(fresh_db) + + root = dialog.tree.topLevelItem(0) + # Color should be shown in column 1 + color_text = root.text(1) + assert color_text.startswith("#") + + +def test_tag_browser_edit_name_button_disabled(app, fresh_db): + """Test that edit button is disabled when no tag selected""" + dialog = TagBrowserDialog(fresh_db) + + assert not dialog.edit_name_btn.isEnabled() + + +def test_tag_browser_edit_name_button_enabled(app, fresh_db): + """Test that edit button is enabled when tag selected""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Select the first item + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + assert dialog.edit_name_btn.isEnabled() + + +def test_tag_browser_delete_button_state(app, fresh_db): + """Test that delete button state changes with selection""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Initially disabled + assert not dialog.delete_btn.isEnabled() + + # Select a tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Should be enabled now + assert dialog.delete_btn.isEnabled() + + +def test_tag_browser_change_color_button_state(app, fresh_db): + """Test that change color button state changes with selection""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Initially disabled + assert not dialog.change_color_btn.isEnabled() + + # Select a tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Should be enabled now + assert dialog.change_color_btn.isEnabled() + + +def test_tag_browser_open_date_signal(app, fresh_db): + """Test that clicking a date emits openDateRequested signal""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + signal_data = {"date": None} + + def on_date_requested(date_iso): + signal_data["date"] = date_iso + + dialog.openDateRequested.connect(on_date_requested) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Get the date child item + date_item = root.child(0) + + # Simulate activation + dialog._on_item_activated(date_item, 0) + + assert signal_data["date"] == "2024-01-15" + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +def test_tag_workflow_end_to_end(app, fresh_db, tmp_path): + """Test complete tag workflow: create, list, update, delete""" + # Create some entries with tags + dates = ["2024-01-15", "2024-01-16", "2024-01-17"] + for date in dates: + fresh_db.save_new_version(date, f"Entry {date}", "note") + + fresh_db.set_tags_for_page(dates[0], ["work", "urgent"]) + fresh_db.set_tags_for_page(dates[1], ["work", "meeting"]) + fresh_db.set_tags_for_page(dates[2], ["personal"]) + + # List all tags - should be 4 unique tags: work, urgent, meeting, personal + all_tags = fresh_db.list_tags() + assert len(all_tags) == 4 + + # Get pages for "work" tag + work_pages = fresh_db.get_pages_for_tag("work") + work_dates = [d for d, _ in work_pages] + assert dates[0] in work_dates + assert dates[1] in work_dates + assert dates[2] not in work_dates + + # Update a tag + work_tag = [t for t in all_tags if t[1] == "work"][0] + fresh_db.update_tag(work_tag[0], "office", "#0000FF") + + # Verify update + updated_tags = fresh_db.list_tags() + office_tag = [t for t in updated_tags if t[1] == "office"][0] + assert office_tag[2] == "#0000FF" + + # Delete a tag + urgent_tag = [t for t in all_tags if t[1] == "urgent"][0] + fresh_db.delete_tag(urgent_tag[0]) + + # Verify deletion - should have 3 tags now (office, meeting, personal) + final_tags = fresh_db.list_tags() + tag_names = [t[1] for t in final_tags] + assert "urgent" not in tag_names + assert len(final_tags) == 3 + + +def test_tags_with_export(fresh_db, tmp_path): + """Test that tags are preserved during export operations""" + date_iso = "2024-01-15" + fresh_db.save_new_version(date_iso, "Content", "note") + fresh_db.set_tags_for_page(date_iso, ["exported", "preserved"]) + + # Export to JSON + entries = fresh_db.get_all_entries() + json_path = tmp_path / "export.json" + fresh_db.export_json(entries, str(json_path)) + + assert json_path.exists() + + # Tags should still be in database + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 2 + + +def test_tags_survive_rekey(fresh_db, tmp_db_cfg): + """Test that tags persist after database rekey""" + date_iso = "2024-01-15" + fresh_db.save_new_version(date_iso, "Content", "note") + fresh_db.set_tags_for_page(date_iso, ["persistent"]) + + # Rekey the database + fresh_db.rekey("new-key-456") + fresh_db.close() + + # Reopen with new key + tmp_db_cfg.key = "new-key-456" + db2 = DBManager(tmp_db_cfg) + assert db2.connect() + + # Tags should still be there + tags = db2.get_tags_for_page(date_iso) + assert len(tags) == 1 + assert tags[0][1] == "persistent" + db2.close() + + +def test_multiple_pages_same_tags(fresh_db): + """Test multiple pages can share the same tags""" + dates = ["2024-01-15", "2024-01-16", "2024-01-17"] + + for date in dates: + fresh_db.save_new_version(date, f"Content {date}", "note") + fresh_db.set_tags_for_page(date, ["shared", "tag"]) + + # All pages should have the tags + for date in dates: + tags = fresh_db.get_tags_for_page(date) + tag_names = [name for _, name, _ in tags] + assert "shared" in tag_names + assert "tag" in tag_names + + # But there should only be 2 unique tags in the database + all_tags = fresh_db.list_tags() + assert len(all_tags) == 2 + + +def test_tag_page_without_content(fresh_db): + """Test that pages can have tags even without content""" + date_iso = "2024-01-15" + + # Set tags without saving any content + fresh_db.set_tags_for_page(date_iso, ["tagonly"]) + + # Tags should be retrievable + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + assert tags[0][1] == "tagonly" + + # Page should be created but with no content + content = fresh_db.get_entry(date_iso) + assert content is None or content == "" + + +# ============================================================================ +# TagChip Mouse Event Tests +# ============================================================================ + + +def test_tag_chip_mouse_click_emits_signal(app, qtbot): + """Test that clicking a TagChip emits the clicked signal""" + chip = TagChip(1, "clickable", "#FF0000") + chip.show() + qtbot.waitExposed(chip) + + signal_data = {"name": None} + + def on_clicked(name): + signal_data["name"] = name + + chip.clicked.connect(on_clicked) + + # Simulate mouse click + event = QMouseEvent( + QEvent.MouseButtonRelease, + QPoint(5, 5), + Qt.LeftButton, + Qt.LeftButton, + Qt.NoModifier, + ) + chip.mouseReleaseEvent(event) + + assert signal_data["name"] == "clickable" + + +def test_tag_chip_right_click_no_signal(app, qtbot): + """Test that right-clicking a TagChip does not emit clicked signal""" + chip = TagChip(1, "clickable", "#FF0000") + chip.show() + qtbot.waitExposed(chip) + + signal_emitted = {"emitted": False} + + def on_clicked(name): + signal_emitted["emitted"] = True + + chip.clicked.connect(on_clicked) + + # Simulate right click + event = QMouseEvent( + QEvent.MouseButtonRelease, + QPoint(5, 5), + Qt.RightButton, + Qt.RightButton, + Qt.NoModifier, + ) + chip.mouseReleaseEvent(event) + + # Signal should NOT be emitted for right click + assert not signal_emitted["emitted"] + + +# ============================================================================ +# PageTagsWidget Edge Cases +# ============================================================================ + + +def test_page_tags_widget_add_tag_with_completer_popup_visible(app, fresh_db): + """Test adding tag when completer popup is visible""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + # Create some existing tags for autocomplete + fresh_db.set_tags_for_page("2024-01-14", ["existing", "another"]) + fresh_db.set_tags_for_page(date_iso, []) + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Type partial text to trigger completer + widget.add_edit.setText("exi") + + # Show the completer popup + completer = widget.add_edit.completer() + if completer: + completer.complete() + + # If popup is now visible, pressing enter should return early + if completer.popup().isVisible(): + # Call _on_add_tag while popup is visible + widget._on_add_tag() + + # Tag should NOT be added since completer popup was visible + tags = fresh_db.get_tags_for_page(date_iso) + # "exi" should not be added as a tag + tag_names = [name for _, name, _ in tags] + assert "exi" not in tag_names + + +def test_page_tags_widget_no_current_date_add_tag(app, fresh_db): + """Test adding tag when no current date is set (early return)""" + widget = PageTagsWidget(fresh_db) + + # Don't set current date + widget.add_edit.setText("test") + widget._on_add_tag() + + # Should handle gracefully and not crash + assert widget._current_date is None + + +def test_page_tags_widget_no_current_date_remove_tag(app, fresh_db): + """Test removing tag when no current date is set""" + widget = PageTagsWidget(fresh_db) + + # Try to remove tag without setting date + widget._remove_tag(1) + + # Should handle gracefully + assert widget._current_date is None + + +# ============================================================================ +# TagBrowserDialog Interactive Tests +# ============================================================================ + + +def test_tag_browser_button_states_with_page_item(app, fresh_db): + """Test that buttons are disabled when clicking a page item""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Get the date child item + date_item = root.child(0) + + # Click the date item + dialog.tree.setCurrentItem(date_item) + dialog._on_item_clicked(date_item, 0) + + # Buttons should be disabled for page items + assert not dialog.edit_name_btn.isEnabled() + assert not dialog.change_color_btn.isEnabled() + assert not dialog.delete_btn.isEnabled() + + +def test_tag_browser_edit_tag_name_no_item(app, fresh_db): + """Test editing tag name when no item is selected""" + dialog = TagBrowserDialog(fresh_db) + + # Try to edit without selecting anything + dialog._edit_tag_name() + + # Should handle gracefully (no exception) + assert True + + +def test_tag_browser_edit_tag_name_page_item(app, fresh_db): + """Test editing tag name when a page item is selected""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item (not a tag) + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to edit - should return early since it's not a tag item + dialog._edit_tag_name() + + # Should handle gracefully + assert True + + +def test_tag_browser_change_color_no_item(app, fresh_db): + """Test changing color when no item is selected""" + dialog = TagBrowserDialog(fresh_db) + + # Try to change color without selecting anything + dialog._change_tag_color() + + # Should handle gracefully + assert True + + +def test_tag_browser_change_color_page_item(app, fresh_db): + """Test changing color when a page item is selected""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to change color - should return early + dialog._change_tag_color() + + # Should handle gracefully + assert True + + +def test_tag_browser_delete_tag_no_item(app, fresh_db): + """Test deleting tag when no item is selected""" + dialog = TagBrowserDialog(fresh_db) + + # Try to delete without selecting anything + dialog._delete_tag() + + # Should handle gracefully + assert True + + +def test_tag_browser_delete_tag_page_item(app, fresh_db): + """Test deleting tag when a page item is selected""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to delete - should return early + dialog._delete_tag() + + # Tag should still exist + tags = fresh_db.list_tags() + assert len(tags) == 1 + + +# ============================================================================ +# FlowLayout Edge Case +# ============================================================================ + + +def test_flow_layout_take_at_out_of_bounds(app): + """Test FlowLayout.takeAt with invalid index""" + layout = FlowLayout() + + # Try to take item at index that doesn't exist + result = layout.takeAt(999) + + # Should return None + assert result is None + + +def test_flow_layout_take_at_negative(app): + """Test FlowLayout.takeAt with negative index""" + layout = FlowLayout() + + # Try to take item at negative index + result = layout.takeAt(-1) + + # Should return None + assert result is None + + +# ============================================================================ +# DB Edge Case for tags +# ============================================================================ + + +def test_db_default_tag_colour_many_tags(fresh_db): + """Test the _default_tag_colour method with many tags""" + # Create many tags to test color assignment logic + tag_names = [f"tag{i}" for i in range(20)] + + for i, name in enumerate(tag_names): + fresh_db.set_tags_for_page(f"2024-01-{i+1:02d}", [name]) + + # Verify all tags have valid colors + tags = fresh_db.list_tags() + for _, name, color in tags: + assert color.startswith("#") + assert len(color) in (4, 7) + + +# ============================================================================ +# Additional PageTagsWidget Coverage +# ============================================================================ + + +def test_page_tags_widget_set_date_while_collapsed(app, fresh_db): + """Test setting date when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Widget is collapsed by default + assert not widget.toggle_btn.isChecked() + + # Set date while collapsed + widget.set_current_date(date_iso) + + # Chips should not be loaded yet (collapsed) + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_expand_then_set_date(app, fresh_db): + """Test expanding widget then setting date""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1"]) + + # Expand first + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Then set date + widget.set_current_date(date_iso) + + # Process events + QApplication.processEvents() + + # Chips should be loaded + assert widget.chip_layout.count() == 1 + + +def test_page_tags_widget_remove_tag_no_date(app, fresh_db): + """Test removing tag when current date is None""" + widget = PageTagsWidget(fresh_db) + + # Current date is None + assert widget._current_date is None + + # Try to remove tag + widget._remove_tag(1) + + # Should handle gracefully + assert True + + +# ============================================================================ +# Signal Connection Tests +# ============================================================================ + + +def test_tag_browser_open_date_signal_works(app, fresh_db): + """Test that openDateRequested signal works properly""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + received_dates = [] + + def date_handler(date_iso): + received_dates.append(date_iso) + + dialog.openDateRequested.connect(date_handler) + + # Get tag item, expand it, and get child date item + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + date_item = root.child(0) + + # Simulate activation (double-click) + dialog._on_item_activated(date_item, 0) + + assert "2024-01-15" in received_dates + + +def test_page_tags_widget_tag_activated_signal_works(app, fresh_db): + """Test tagActivated signal emission""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["clicktag"]) + widget.set_current_date(date_iso) + + received_data = [] + + def tag_handler(data): + received_data.append(data) + + widget.tagActivated.connect(tag_handler) + + # Directly call the method + widget._on_chip_clicked("clicktag") + + assert "clicktag" in received_data + + +# ============================================================================ +# Additional Edge Cases +# ============================================================================ + + +def test_page_tags_widget_clear_chips_when_no_items(app, fresh_db): + """Test clearing chips when layout is empty""" + widget = PageTagsWidget(fresh_db) + + # Clear when empty + widget._clear_chips() + + # Should handle gracefully + assert widget.chip_layout.count() == 0 + + +def test_tag_browser_populate_with_no_focus_tag(app, fresh_db): + """Test populating browser without focus tag""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag=None) + + # Should have both tags + assert dialog.tree.topLevelItemCount() == 2 + + +def test_tag_browser_populate_with_nonexistent_focus_tag(app, fresh_db): + """Test populating browser with focus tag that doesn't exist""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag="nonexistent") + + # Should handle gracefully + assert dialog.tree.topLevelItemCount() == 1 + + +# ============================================================================ +# PageTagsWidget Edge Cases +# ============================================================================ + + +def test_page_tags_widget_reload_without_current_date(app, fresh_db): + """Test _reload_tags with no current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Try to reload without setting a date + widget._reload_tags() + + # Should handle gracefully + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_add_tag_without_current_date(app, fresh_db): + """Test trying to add tag without current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + widget.add_edit.setText("shouldnotadd") + widget._on_add_tag() + + # Should not crash, and no tags should be in database + all_tags = fresh_db.list_tags() + assert len(all_tags) == 0 + + +def test_page_tags_widget_completer_popup_visible_skip(app, fresh_db): + """Test that _on_add_tag returns early if completer popup is visible""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + # Create some existing tags for autocomplete + fresh_db.set_tags_for_page(date_iso, ["existing1", "existing2"]) + widget.set_current_date(date_iso) + widget._setup_autocomplete() + + # Make completer popup visible + widget.add_edit.setText("ex") + completer = widget.add_edit.completer() + if completer: + completer.popup().show() + + # Try to add tag while popup is visible + initial_count = len(fresh_db.get_tags_for_page(date_iso)) + widget._on_add_tag() + + # Should return early, not add anything + assert len(fresh_db.get_tags_for_page(date_iso)) == initial_count + + +def test_page_tags_widget_set_date_when_collapsed(app, fresh_db): + """Test setting date when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Ensure widget is collapsed + widget.toggle_btn.setChecked(False) + + # Set date - should clear chips since collapsed + widget.set_current_date(date_iso) + + assert widget._current_date == date_iso + # Chips should be cleared when collapsed + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_set_date_when_expanded(app, fresh_db): + """Test setting date when widget is expanded""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Expand widget + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Set date - should reload tags since expanded + widget.set_current_date(date_iso) + + assert widget.chip_layout.count() == 2 + + +def test_page_tags_widget_toggle_without_date(app, fresh_db): + """Test toggling widget without a current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Try to expand without setting a date + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Should not crash + assert widget.body.isVisible() + + +# ============================================================================ +# TagBrowserDialog User Interaction Tests +# ============================================================================ + + +def test_tag_browser_click_page_item_disables_buttons(app, fresh_db): + """Test that clicking a page item (not tag) disables edit buttons""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Click the page child item + page_item = root.child(0) + dialog.tree.setCurrentItem(page_item) + dialog._on_item_clicked(page_item, 0) + + # Buttons should be disabled for page items + assert not dialog.edit_name_btn.isEnabled() + assert not dialog.change_color_btn.isEnabled() + assert not dialog.delete_btn.isEnabled() + + +def test_tag_browser_edit_name_no_item_selected(app, fresh_db): + """Test _edit_tag_name with no item selected""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Clear selection + dialog.tree.setCurrentItem(None) + + # Try to edit - should return early + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "test" + + +def test_tag_browser_edit_name_page_item_selected(app, fresh_db): + """Test _edit_tag_name with page item selected (should return early)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select a page item + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + page_item = root.child(0) + dialog.tree.setCurrentItem(page_item) + + # Try to edit - should return early + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "test" + + +def test_tag_browser_edit_name_cancelled(app, fresh_db, monkeypatch): + """Test _edit_tag_name when user cancels the dialog""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select the tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return cancelled (ok=False) + def mock_get_text(*args, **kwargs): + return ("newname", False) # User cancelled + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "oldname" + + +def test_tag_browser_edit_name_empty_name(app, fresh_db, monkeypatch): + """Test _edit_tag_name with empty name""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return empty string + def mock_get_text(*args, **kwargs): + return ("", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "oldname" + + +def test_tag_browser_edit_name_same_name(app, fresh_db, monkeypatch): + """Test _edit_tag_name with same name (no change)""" + fresh_db.set_tags_for_page("2024-01-15", ["samename"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return same name + def mock_get_text(*args, **kwargs): + return ("samename", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "samename" + + +def test_tag_browser_edit_name_success(app, fresh_db, monkeypatch): + """Test successfully editing a tag name""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return new name + def mock_get_text(*args, **kwargs): + return ("newname", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be updated + tags = fresh_db.list_tags() + assert tags[0][1] == "newname" + + +def test_tag_browser_change_color_cancelled(app, fresh_db, monkeypatch): + """Test _change_tag_color when user cancels""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + original_color = fresh_db.list_tags()[0][2] + + # Mock QColorDialog.getColor to return invalid color (cancelled) + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor() # Invalid color + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + dialog._change_tag_color() + + # Color should be unchanged + assert fresh_db.list_tags()[0][2] == original_color + + +def test_tag_browser_change_color_success(app, fresh_db, monkeypatch): + """Test successfully changing tag color""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QColorDialog.getColor to return blue + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor("#0000FF") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + dialog._change_tag_color() + + # Color should be updated + tags = fresh_db.list_tags() + assert tags[0][2] == "#0000ff" # Qt lowercases hex colors + + +def test_tag_browser_delete_tag_cancelled(app, fresh_db, monkeypatch): + """Test _delete_tag when user cancels confirmation""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QMessageBox.question to return No + def mock_question(*args, **kwargs): + return QMessageBox.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_tag() + + # Tag should still exist + assert len(fresh_db.list_tags()) == 1 + + +def test_tag_browser_delete_tag_confirmed(app, fresh_db, monkeypatch): + """Test successfully deleting a tag after confirmation""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QMessageBox.question to return Yes + def mock_question(*args, **kwargs): + return QMessageBox.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_tag() + + # Tag should be deleted + assert len(fresh_db.list_tags()) == 0 + + +# ============================================================================ +# DB Edge Cases +# ============================================================================ + + +def test_default_tag_colour_empty_name(fresh_db): + """Test _default_tag_colour with empty string""" + color = fresh_db._default_tag_colour("") + assert color == "#CCCCCC" + + +def test_default_tag_colour_none(fresh_db): + """Test _default_tag_colour with None (should handle edge case)""" + # This tests the "if not name:" condition + color = fresh_db._default_tag_colour("") + assert color == "#CCCCCC" + + +# ============================================================================ +# FlowLayout Edge Cases +# ============================================================================ + + +def test_flow_layout_take_at_invalid_index(app): + """Test FlowLayout.takeAt with out-of-bounds index""" + from PySide6.QtWidgets import QWidget, QLabel + + widget = QWidget() + layout = FlowLayout(widget) + + # Add some items + layout.addWidget(QLabel("Item 1")) + layout.addWidget(QLabel("Item 2")) + + # Try to take at invalid negative index + result = layout.takeAt(-1) + assert result is None + + # Try to take at index beyond count + result = layout.takeAt(100) + assert result is None + + # Valid index should work + result = layout.takeAt(0) + assert result is not None + + +def test_flow_layout_take_at_boundary(app): + """Test FlowLayout.takeAt at exact boundary""" + from PySide6.QtWidgets import QWidget, QLabel + + widget = QWidget() + layout = FlowLayout(widget) + + # Add items + layout.addWidget(QLabel("Item 1")) + layout.addWidget(QLabel("Item 2")) + + count = layout.count() + + # Try to take at count (should be out of bounds) + result = layout.takeAt(count) + assert result is None + + # Take at count-1 (should work) + result = layout.takeAt(count - 1) + assert result is not None + + +# ============================================================================ +# Integration Tests for Complete Coverage +# ============================================================================ + + +def test_complete_tag_lifecycle_with_browser(app, fresh_db, monkeypatch): + """Test complete tag lifecycle: create, view in browser, edit, delete""" + # Create a tag + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["lifecycle"]) + + # Open browser + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select the tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Edit name + def mock_get_text(*args, **kwargs): + return ("renamed", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + dialog._edit_tag_name() + + # After _edit_tag_name calls _populate(), need to re-select the item + # as the tree was rebuilt + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Change color + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor("#FF0000") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + dialog._change_tag_color() + + # Verify changes + tags = fresh_db.list_tags() + assert tags[0][1] == "renamed" + # Qt lowercases hex colors + assert tags[0][2].lower() == "#ff0000" + + # Delete tag - need to re-select after _change_tag_color also calls _populate + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + def mock_question(*args, **kwargs): + return QMessageBox.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + dialog._delete_tag() + + # Tag should be gone + assert len(fresh_db.list_tags()) == 0 + + +def test_tag_widget_with_completer_interaction(app, fresh_db): + """Test tag widget with autocomplete interaction""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Create some tags + date1 = "2024-01-15" + fresh_db.set_tags_for_page(date1, ["alpha", "beta", "gamma"]) + + # Set up widget with different date + date2 = "2024-01-16" + widget.set_current_date(date2) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Autocomplete should have previous tags + completer = widget.add_edit.completer() + assert completer is not None + + # Add a tag that exists in autocomplete + widget.add_edit.setText("alpha") + widget._on_add_tag() + + # Should be added to current page + tags = fresh_db.get_tags_for_page(date2) + tag_names = [name for _, name, _ in tags] + assert "alpha" in tag_names + + +def test_multiple_widgets_same_database(app, fresh_db): + """Test multiple tag widgets operating on same database""" + widget1 = PageTagsWidget(fresh_db) + widget2 = PageTagsWidget(fresh_db) + + widget1.show() + widget2.show() + + date_iso = "2024-01-15" + + # Widget 1 adds a tag + widget1.set_current_date(date_iso) + widget1.toggle_btn.setChecked(True) + widget1._on_toggle(True) + widget1.add_edit.setText("shared") + widget1._on_add_tag() + + # Widget 2 should see it when set to same date + widget2.set_current_date(date_iso) + widget2.toggle_btn.setChecked(True) + widget2._on_toggle(True) + + assert widget2.chip_layout.count() == 1 + + +def test_tag_browser_add_tag_with_color(qtbot, fresh_db, monkeypatch): + """Test adding a new tag with color selection.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Mock input dialog and color dialog + def mock_get_text(*args, **kwargs): + return "NewTag", True + + def mock_get_color(initial, parent): + return QColor("#ff0000") + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + tags_before = len(fresh_db.list_tags()) + + # Trigger add tag + browser._add_a_tag() + + # Should have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + 1 + + +def test_tag_browser_add_tag_cancelled_at_name(qtbot, fresh_db, monkeypatch): + """Test cancelling tag addition at name input.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Mock cancelled input + def mock_get_text(*args, **kwargs): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + tags_before = len(fresh_db.list_tags()) + + browser._add_a_tag() + + # Should not have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + + +def test_tag_browser_add_tag_cancelled_at_color(qtbot, fresh_db, monkeypatch): + """Test cancelling tag addition at color selection.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Name input succeeds, color cancelled + def mock_get_text(*args, **kwargs): + return "NewTag", True + + def mock_get_color(initial, parent): + return QColor() # Invalid color = cancelled + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + tags_before = len(fresh_db.list_tags()) + + browser._add_a_tag() + + # Should not have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + + +def test_tag_browser_add_duplicate_tag_shows_error(qtbot, fresh_db, monkeypatch): + """Test adding duplicate tag shows error.""" + strings.load_strings("en") + + # Add existing tag + fresh_db.add_tag("Existing", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Try to add same tag + def mock_get_text(*args, **kwargs): + return "Existing", True + + def mock_get_color(initial, parent): + return QColor("#00ff00") + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._add_a_tag() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_edit_tag_integrity_error(qtbot, fresh_db, monkeypatch): + """Test editing tag to duplicate name shows error.""" + strings.load_strings("en") + + # Add two tags + fresh_db.add_tag("Tag1", "#ff0000") + fresh_db.add_tag("Tag2", "#00ff00") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Select first tag + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Try to rename to Tag2 (duplicate) + def mock_get_text(*args, **kwargs): + return "Tag2", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._edit_tag_name() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_change_tag_color_integrity_error(qtbot, fresh_db, monkeypatch): + """Test changing tag color with integrity error.""" + strings.load_strings("en") + + fresh_db.add_tag("TestTag", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Mock color dialog + def mock_get_color(initial, parent): + return QColor("#00ff00") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + # Mock update_tag to raise IntegrityError + fresh_db.update_tag + + def bad_update(*args): + raise IntegrityError("Simulated error") + + monkeypatch.setattr(fresh_db, "update_tag", bad_update) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._change_tag_color() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_change_tag_color_cancelled(qtbot, fresh_db, monkeypatch): + """Test cancelling color change.""" + strings.load_strings("en") + + fresh_db.add_tag("TestTag", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Mock cancelled color dialog + def mock_get_color(initial, parent): + return QColor() # Invalid = cancelled + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + # Should not crash + browser._change_tag_color() + + +def test_tag_chip_runtime_error_on_mouse_release(qtbot, monkeypatch): + """Test TagChip handles RuntimeError on mouseReleaseEvent.""" + chip = TagChip(1, "test", "#ff0000") + qtbot.addWidget(chip) + chip.show() + + # Mock super().mouseReleaseEvent to raise RuntimeError + from PySide6.QtWidgets import QFrame + + original_mouse_release = QFrame.mouseReleaseEvent + + def bad_mouse_release(self, event): + raise RuntimeError("Widget deleted") + + monkeypatch.setattr(QFrame, "mouseReleaseEvent", bad_mouse_release) + + clicked_names = [] + chip.clicked.connect(clicked_names.append) + + # Simulate left click + from PySide6.QtTest import QTest + + QTest.mouseClick(chip, Qt.LeftButton) + + # Should have emitted signal despite RuntimeError + assert "test" in clicked_names + + # Restore original + monkeypatch.setattr(QFrame, "mouseReleaseEvent", original_mouse_release) + + +def test_page_tags_widget_many_tags(qtbot, fresh_db): + """Test page tags widget with many tags.""" + strings.load_strings("en") + + # Add many tags + for i in range(20): + fresh_db.add_tag(f"Tag{i}", f"#{i:02x}0000") + + fresh_db.save_new_version("2024-01-01", "Content", "test") + + # Add all tags to page + tag_names = [f"Tag{i}" for i in range(20)] + fresh_db.set_tags_for_page("2024-01-01", tag_names) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Set current date + widget.set_current_date("2024-01-01") + + # Should display all tags + qtbot.wait(50) + + +def test_page_tags_widget_tag_click(qtbot, fresh_db): + """Test clicking on a tag in PageTagsWidget.""" + strings.load_strings("en") + + fresh_db.add_tag("Clickable", "#ff0000") + fresh_db.save_new_version("2024-01-01", "Content", "test") + fresh_db.set_tags_for_page("2024-01-01", ["Clickable"]) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Find the tag chip + chips = widget.findChildren(TagChip) + assert len(chips) > 0 + + # Click it - shouldn't crash + from PySide6.QtTest import QTest + + QTest.mouseClick(chips[0], Qt.LeftButton) + + +def test_page_tags_widget_no_date_set(qtbot, fresh_db): + """Test PageTagsWidget with no date set.""" + strings.load_strings("en") + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Should handle no date gracefully + widget.set_current_date(None) + qtbot.wait(10) + + +def test_page_tags_widget_date_with_no_tags(qtbot, fresh_db): + """Test PageTagsWidget for date with no tags.""" + strings.load_strings("en") + + fresh_db.save_new_version("2024-01-01", "Content", "test") + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + + # Should show no tags + pills = widget.findChildren(TagChip) + assert len(pills) == 0 + + +def test_page_tags_widget_updates_on_tag_change(qtbot, fresh_db): + """Test PageTagsWidget updates when tags change.""" + strings.load_strings("en") + + fresh_db.add_tag("Initial", "#ff0000") + fresh_db.save_new_version("2024-01-01", "Content", "test") + fresh_db.set_tags_for_page("2024-01-01", ["Initial"]) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + assert widget.chip_layout.count() == 1 + + # Add another tag + fresh_db.add_tag("Second", "#00ff00") + fresh_db.set_tags_for_page("2024-01-01", ["Initial", "Second"]) + + # Reload + widget.set_current_date("2024-01-01") + qtbot.wait(100) + + assert widget.chip_layout.count() == 2 + + +def test_tags_widget_open_manager_and_accept(qtbot, tmp_db_cfg, fresh_db, monkeypatch): + """Test opening tag manager dialog and accepting - covers lines 248-256""" + tags_widget = PageTagsWidget(fresh_db) + qtbot.addWidget(tags_widget) + + # Set a current date + date = QDate.currentDate().toString("yyyy-MM-dd") + tags_widget.set_current_date(date) + + # Add some tags first + fresh_db.add_tag("Test Tag", date) + tags_widget._reload_tags() + + # Mock the tag browser dialog + from bouquin.tag_browser import TagBrowserDialog + + dialog_executed = [] + + def fake_exec(self): + dialog_executed.append(True) + # Simulate the dialog being accepted + return QDialog.Accepted + + monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec) + + # Open the manager + tags_widget._open_manager() + qtbot.wait(50) + + # Dialog should have been executed + assert len(dialog_executed) > 0 + + +def test_tags_widget_open_manager_and_reject(qtbot, tmp_db_cfg, fresh_db, monkeypatch): + """Test opening tag manager dialog and rejecting""" + tags_widget = PageTagsWidget(fresh_db) + qtbot.addWidget(tags_widget) + + # Set a current date + date = QDate.currentDate().toString("yyyy-MM-dd") + tags_widget.set_current_date(date) + + # Mock the tag browser dialog + from bouquin.tag_browser import TagBrowserDialog + + dialog_executed = [] + + def fake_exec(self): + dialog_executed.append(True) + # Simulate the dialog being rejected + return QDialog.Rejected + + monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec) + + # Open the manager + tags_widget._open_manager() + qtbot.wait(50) + + # Dialog should have been executed + assert len(dialog_executed) > 0 + + +def test_tags_widget_open_manager_without_current_date( + qtbot, tmp_db_cfg, fresh_db, monkeypatch +): + """Test opening tag manager when no current date is set""" + tags_widget = PageTagsWidget(fresh_db) + qtbot.addWidget(tags_widget) + + # Don't set a current date + tags_widget._current_date = None + + # Mock the tag browser dialog + from bouquin.tag_browser import TagBrowserDialog + + dialog_executed = [] + + def fake_exec(self): + dialog_executed.append(True) + return QDialog.Accepted + + monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec) + + # Open the manager + tags_widget._open_manager() + qtbot.wait(50) + + # Dialog should still execute + assert len(dialog_executed) > 0 + + +def test_tags_widget_manager_with_date_click_signal( + qtbot, tmp_db_cfg, fresh_db, monkeypatch +): + """Test tag manager emitting openDateRequested signal""" + tags_widget = PageTagsWidget(fresh_db) + qtbot.addWidget(tags_widget) + + date = QDate.currentDate().toString("yyyy-MM-dd") + tags_widget.set_current_date(date) + + activated_tags = [] + + def capture_tag(tag): + activated_tags.append(tag) + + tags_widget.tagActivated.connect(capture_tag) + + # Mock the tag browser dialog + from bouquin.tag_browser import TagBrowserDialog + + def fake_exec(self): + # Simulate clicking a date in the browser + self.openDateRequested.emit("2024-01-01") + return QDialog.Accepted + + monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec) + + # Open the manager + tags_widget._open_manager() + qtbot.wait(50) + + # Should have captured the activated tag/date + assert len(activated_tags) > 0 + assert "2024-01-01" in activated_tags + + +def test_tags_widget_chip_click(qtbot, tmp_db_cfg, fresh_db): + """Test clicking on a tag chip""" + tags_widget = PageTagsWidget(fresh_db) + qtbot.addWidget(tags_widget) + + date = QDate.currentDate().toString("yyyy-MM-dd") + tags_widget.set_current_date(date) + + # Add a tag + fresh_db.add_tag("ClickMe", date) + tags_widget._reload_tags() + + activated_tags = [] + + def capture_tag(tag): + activated_tags.append(tag) + + tags_widget.tagActivated.connect(capture_tag) + + # Simulate chip click + tags_widget._on_chip_clicked("ClickMe") + qtbot.wait(50) + + assert "ClickMe" in activated_tags diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..6f19a62 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,52 @@ +from PySide6.QtGui import QPalette +from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget + +from bouquin.theme import Theme, ThemeConfig, ThemeManager + + +def test_theme_manager_apply_light_and_dark(app): + cfg = ThemeConfig(theme=Theme.LIGHT) + mgr = ThemeManager(app, cfg) + mgr.apply(Theme.LIGHT) + assert isinstance(app.palette(), QPalette) + + mgr.set(Theme.DARK) + assert isinstance(app.palette(), QPalette) + + +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) diff --git a/tests/test_theme_integration.py b/tests/test_theme_integration.py deleted file mode 100644 index f1949c3..0000000 --- a/tests/test_theme_integration.py +++ /dev/null @@ -1,19 +0,0 @@ -from bouquin.theme import Theme - - -def test_apply_link_css_dark_theme(open_window, qtbot): - win = open_window - # Switch to dark and apply link CSS - win.themes.set(Theme.DARK) - win._apply_link_css() - css = win.editor.document().defaultStyleSheet() - assert "#FFA500" in css and "a:visited" in css - - -def test_apply_link_css_light_theme(open_window, qtbot): - win = open_window - # Switch to light and apply link CSS - win.themes.set(Theme.LIGHT) - win._apply_link_css() - css = win.editor.document().defaultStyleSheet() - assert css == "" or "a {" not in css diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py deleted file mode 100644 index 39121ea..0000000 --- a/tests/test_theme_manager.py +++ /dev/null @@ -1,19 +0,0 @@ -from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QPalette, QColor - -from bouquin.theme import ThemeManager, ThemeConfig, Theme - - -def test_theme_manager_applies_palettes(qtbot): - app = QApplication.instance() - tm = ThemeManager(app, ThemeConfig()) - - # Light palette should set Link to the light blue - tm.apply(Theme.LIGHT) - pal = app.palette() - assert pal.color(QPalette.Link) == QColor("#1a73e8") - - # Dark palette should set Link to lavender-ish - tm.apply(Theme.DARK) - pal = app.palette() - assert pal.color(QPalette.Link) == QColor("#FFA500") diff --git a/tests/test_time_log.py b/tests/test_time_log.py new file mode 100644 index 0000000..68dad54 --- /dev/null +++ b/tests/test_time_log.py @@ -0,0 +1,2598 @@ +import pytest +from datetime import date, timedelta +from PySide6.QtCore import Qt, QDate +from PySide6.QtWidgets import ( + QMessageBox, + QInputDialog, + QFileDialog, +) +from sqlcipher3.dbapi2 import IntegrityError + +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.time_log import ( + TimeLogWidget, + TimeLogDialog, + TimeCodeManagerDialog, + TimeReportDialog, +) +import bouquin.strings as strings + + +@pytest.fixture +def theme_manager(app): + return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + +def _today(): + return date.today().isoformat() + + +def _yesterday(): + return (date.today() - timedelta(days=1)).isoformat() + + +def _tomorrow(): + return (date.today() + timedelta(days=1)).isoformat() + + +# ============================================================================ +# DB Methods Tests +# ============================================================================ + + +def test_list_projects_empty(fresh_db): + """List projects on empty db returns empty list.""" + projects = fresh_db.list_projects() + assert projects == [] + + +def test_add_project(fresh_db): + """Add a project and verify it's retrievable.""" + proj_id = fresh_db.add_project("Project Alpha") + assert proj_id > 0 + + projects = fresh_db.list_projects() + assert len(projects) == 1 + assert projects[0] == (proj_id, "Project Alpha") + + +def test_add_project_duplicate_name(fresh_db): + """Adding project with duplicate name is idempotent.""" + id1 = fresh_db.add_project("Duplicate") + id2 = fresh_db.add_project("Duplicate") + assert id1 == id2 + + projects = fresh_db.list_projects() + assert len(projects) == 1 + + +def test_add_project_empty_name(fresh_db): + """Adding project with empty name raises ValueError.""" + with pytest.raises(ValueError, match="empty project name"): + fresh_db.add_project("") + + with pytest.raises(ValueError, match="empty project name"): + fresh_db.add_project(" ") + + +def test_add_project_strips_whitespace(fresh_db): + """Project name is trimmed of leading/trailing whitespace.""" + fresh_db.add_project(" Trimmed ") + projects = fresh_db.list_projects() + assert projects[0][1] == "Trimmed" + + +def test_list_projects_sorted(fresh_db): + """Projects are returned sorted case-insensitively by name.""" + fresh_db.add_project("Zebra") + fresh_db.add_project("alpha") + fresh_db.add_project("Beta") + + projects = fresh_db.list_projects() + names = [p[1] for p in projects] + assert names == ["alpha", "Beta", "Zebra"] + + +def test_rename_project(fresh_db): + """Rename a project.""" + proj_id = fresh_db.add_project("Old Name") + fresh_db.rename_project(proj_id, "New Name") + + projects = fresh_db.list_projects() + assert len(projects) == 1 + assert projects[0] == (proj_id, "New Name") + + +def test_rename_project_to_existing_name_raises(fresh_db): + """Renaming to existing name raises IntegrityError.""" + fresh_db.add_project("Project A") + id_b = fresh_db.add_project("Project B") + + with pytest.raises(IntegrityError): + fresh_db.rename_project(id_b, "Project A") + + +def test_rename_project_empty_name_does_nothing(fresh_db): + """Renaming to empty string does nothing.""" + proj_id = fresh_db.add_project("Original") + fresh_db.rename_project(proj_id, "") + + projects = fresh_db.list_projects() + assert projects[0][1] == "Original" + + +def test_delete_project(fresh_db): + """Delete a project.""" + proj_id = fresh_db.add_project("To Delete") + fresh_db.delete_project(proj_id) + + projects = fresh_db.list_projects() + assert len(projects) == 0 + + +def test_delete_project_with_time_entries_raises(fresh_db): + """Deleting project with time entries raises IntegrityError.""" + proj_id = fresh_db.add_project("Active Project") + act_id = fresh_db.add_activity("Coding") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + with pytest.raises(IntegrityError): + fresh_db.delete_project(proj_id) + + +def test_list_activities_empty(fresh_db): + """List activities on empty db returns empty list.""" + activities = fresh_db.list_activities() + assert activities == [] + + +def test_add_activity(fresh_db): + """Add an activity and verify it's retrievable.""" + act_id = fresh_db.add_activity("Coding") + assert act_id > 0 + + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0] == (act_id, "Coding") + + +def test_add_activity_duplicate_name(fresh_db): + """Adding activity with duplicate name is idempotent.""" + id1 = fresh_db.add_activity("Meeting") + id2 = fresh_db.add_activity("Meeting") + assert id1 == id2 + + activities = fresh_db.list_activities() + assert len(activities) == 1 + + +def test_add_activity_empty_name(fresh_db): + """Adding activity with empty name raises ValueError.""" + with pytest.raises(ValueError, match="empty activity name"): + fresh_db.add_activity("") + + with pytest.raises(ValueError, match="empty activity name"): + fresh_db.add_activity(" ") + + +def test_add_activity_strips_whitespace(fresh_db): + """Activity name is trimmed of leading/trailing whitespace.""" + fresh_db.add_activity(" Planning ") + activities = fresh_db.list_activities() + assert activities[0][1] == "Planning" + + +def test_list_activities_sorted(fresh_db): + """Activities are returned sorted case-insensitively by name.""" + fresh_db.add_activity("Writing") + fresh_db.add_activity("coding") + fresh_db.add_activity("Planning") + + activities = fresh_db.list_activities() + names = [a[1] for a in activities] + assert names == ["coding", "Planning", "Writing"] + + +def test_rename_activity(fresh_db): + """Rename an activity.""" + act_id = fresh_db.add_activity("Old Activity") + fresh_db.rename_activity(act_id, "New Activity") + + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0] == (act_id, "New Activity") + + +def test_rename_activity_to_existing_name_raises(fresh_db): + """Renaming to existing name raises IntegrityError.""" + fresh_db.add_activity("Activity A") + id_b = fresh_db.add_activity("Activity B") + + with pytest.raises(IntegrityError): + fresh_db.rename_activity(id_b, "Activity A") + + +def test_rename_activity_empty_name_does_nothing(fresh_db): + """Renaming to empty string does nothing.""" + act_id = fresh_db.add_activity("Original") + fresh_db.rename_activity(act_id, "") + + activities = fresh_db.list_activities() + assert activities[0][1] == "Original" + + +def test_delete_activity(fresh_db): + """Delete an activity.""" + act_id = fresh_db.add_activity("To Delete") + fresh_db.delete_activity(act_id) + + activities = fresh_db.list_activities() + assert len(activities) == 0 + + +def test_delete_activity_with_time_entries_raises(fresh_db): + """Deleting activity with time entries raises IntegrityError.""" + proj_id = fresh_db.add_project("Some Project") + act_id = fresh_db.add_activity("Used Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + with pytest.raises(IntegrityError): + fresh_db.delete_activity(act_id) + + +def test_add_time_log(fresh_db): + """Add a time log entry.""" + proj_id = fresh_db.add_project("Research") + act_id = fresh_db.add_activity("Reading") + + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 90, "Paper review") + assert entry_id > 0 + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + entry = entries[0] + assert entry[0] == entry_id + assert entry[1] == _today() + assert entry[2] == proj_id + assert entry[3] == "Research" + assert entry[4] == act_id + assert entry[5] == "Reading" + assert entry[6] == 90 + assert entry[7] == "Paper review" + + +def test_add_time_log_creates_page_if_needed(fresh_db): + """Adding time log creates page row if it doesn't exist.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Verify page doesn't exist + dates = fresh_db.dates_with_content() + assert _today() not in dates + + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + # Page should now exist (even with no text content) + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + + +def test_add_time_log_without_note(fresh_db): + """Add time log without note (None).""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj_id, act_id, 30) + entries = fresh_db.time_log_for_date(_today()) + assert entries[0][7] is None + + +def test_update_time_log(fresh_db): + """Update an existing time log entry.""" + proj1_id = fresh_db.add_project("Project 1") + proj2_id = fresh_db.add_project("Project 2") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + entry_id = fresh_db.add_time_log(_today(), proj1_id, act1_id, 60, "Original") + + fresh_db.update_time_log(entry_id, proj2_id, act2_id, 120, "Updated") + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + entry = entries[0] + assert entry[0] == entry_id + assert entry[2] == proj2_id + assert entry[3] == "Project 2" + assert entry[4] == act2_id + assert entry[5] == "Activity 2" + assert entry[6] == 120 + assert entry[7] == "Updated" + + +def test_delete_time_log(fresh_db): + """Delete a time log entry.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60) + fresh_db.delete_time_log(entry_id) + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 0 + + +def test_time_log_for_date_empty(fresh_db): + """Query time log for date with no entries.""" + entries = fresh_db.time_log_for_date(_today()) + assert entries == [] + + +def test_time_log_for_date_multiple_entries(fresh_db): + """Query returns multiple entries sorted by project, activity, id.""" + proj_a = fresh_db.add_project("AAA") + proj_b = fresh_db.add_project("BBB") + act_x = fresh_db.add_activity("XXX") + act_y = fresh_db.add_activity("YYY") + + # Add in non-sorted order + fresh_db.add_time_log(_today(), proj_b, act_y, 10) + fresh_db.add_time_log(_today(), proj_a, act_x, 20) + fresh_db.add_time_log(_today(), proj_a, act_y, 30) + fresh_db.add_time_log(_today(), proj_b, act_x, 40) + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 4 + + # Should be sorted by project name, then activity name + assert entries[0][3] == "AAA" and entries[0][5] == "XXX" + assert entries[1][3] == "AAA" and entries[1][5] == "YYY" + assert entries[2][3] == "BBB" and entries[2][5] == "XXX" + assert entries[3][3] == "BBB" and entries[3][5] == "YYY" + + +def test_time_report_by_day(fresh_db): + """Time report grouped by day.""" + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + # Add entries for multiple days + fresh_db.add_time_log(_yesterday(), proj_id, act1_id, 60) + fresh_db.add_time_log(_yesterday(), proj_id, act2_id, 30) + fresh_db.add_time_log(_today(), proj_id, act1_id, 90) + fresh_db.add_time_log(_today(), proj_id, act2_id, 45) + + report = fresh_db.time_report(proj_id, _yesterday(), _today(), "day") + + assert len(report) == 4 + # Each row is (period, activity_name, total_minutes) + yesterday_act1 = next( + r for r in report if r[0] == _yesterday() and r[1] == "Activity 1" + ) + assert yesterday_act1[3] == 60 + + today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1") + assert today_act1[3] == 90 + + +def test_time_report_by_week(fresh_db): + """Time report grouped by week.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries spanning multiple weeks + date1 = "2024-01-01" # Monday, Week 1 + date2 = "2024-01-03" # Same week + date3 = "2024-01-08" # Following Monday, Week 2 + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 30) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + report = fresh_db.time_report(proj_id, date1, date3, "week") + + # Should have 2 rows (2 weeks) + assert len(report) == 2 + + # First week total + assert report[0][3] == 90 # 60 + 30 + # Second week total + assert report[1][3] == 45 + + +def test_time_report_by_month(fresh_db): + """Time report grouped by month.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries spanning multiple months + date1 = "2024-01-15" + date2 = "2024-01-25" + date3 = "2024-02-10" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 30) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + report = fresh_db.time_report(proj_id, date1, date3, "month") + + # Should have 2 rows (2 months) + assert len(report) == 2 + + # January total + jan_row = next(r for r in report if r[0] == "2024-01") + assert jan_row[3] == 90 # 60 + 30 + + # February total + feb_row = next(r for r in report if r[0] == "2024-02") + assert feb_row[3] == 45 + + +def test_time_report_multiple_activities(fresh_db): + """Time report aggregates by activity within period.""" + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + fresh_db.add_time_log(_today(), proj_id, act1_id, 60) + fresh_db.add_time_log(_today(), proj_id, act1_id, 30) # Same activity + fresh_db.add_time_log(_today(), proj_id, act2_id, 45) + + report = fresh_db.time_report(proj_id, _today(), _today(), "day") + + assert len(report) == 2 + act1_row = next(r for r in report if r[1] == "Activity 1") + assert act1_row[3] == 90 # 60 + 30 aggregated + + act2_row = next(r for r in report if r[1] == "Activity 2") + assert act2_row[3] == 45 + + +def test_time_report_filters_by_project(fresh_db): + """Time report only includes specified project.""" + proj1_id = fresh_db.add_project("Project 1") + proj2_id = fresh_db.add_project("Project 2") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + fresh_db.add_time_log(_today(), proj2_id, act_id, 90) + + report = fresh_db.time_report(proj1_id, _today(), _today(), "day") + + assert len(report) == 1 + assert report[0][3] == 60 + + +def test_time_report_empty(fresh_db): + """Time report with no matching entries.""" + proj_id = fresh_db.add_project("Project") + + report = fresh_db.time_report(proj_id, _today(), _today(), "day") + assert report == [] + + +# ============================================================================ +# TimeLogWidget Tests +# ============================================================================ + + +def test_time_log_widget_toggle(qtbot, fresh_db): + """Toggle expands/collapses the widget.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Initially collapsed + assert not widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.RightArrow + + # Toggle to expand + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + assert widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.DownArrow + + # Toggle to collapse + widget.toggle_btn.setChecked(False) + widget._on_toggle(False) + assert not widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.RightArrow + + +def test_time_log_widget_set_current_date_no_entries(qtbot, fresh_db): + """Set current date with no entries.""" + strings.load_strings("en") + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + widget.set_current_date(_today()) + assert widget._current_date == _today() + + # When collapsed, shows hint + assert "Time log" in widget.summary_label.text() + + +def test_time_log_widget_set_current_date_with_entries(qtbot, fresh_db): + """Set current date with entries shows summary.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) # 1.5 hours + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Expand first + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + widget.set_current_date(_today()) + + # Should show total in title + assert "1.5" in widget.toggle_btn.text() or "1.50" in widget.toggle_btn.text() + + # Body should show breakdown + summary = widget.summary_label.text() + assert "Project" in summary + + +def test_time_log_widget_open_dialog(qtbot, fresh_db, monkeypatch): + """Open dialog button opens TimeLogDialog.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + dialog_shown = {"shown": False} + + def mock_exec(self): + dialog_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + assert dialog_shown["shown"] + + +def test_time_log_widget_no_date_open_dialog_does_nothing(qtbot, fresh_db, monkeypatch): + """Open dialog with no date set does nothing.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + dialog_shown = {"shown": False} + + def mock_exec(self): + dialog_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + assert not dialog_shown["shown"] + + +def test_time_log_widget_updates_after_dialog_close(qtbot, fresh_db, monkeypatch): + """Widget refreshes summary after dialog closes.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + # Add entry via DB (simulating dialog action) + def mock_exec(self): + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + + # Summary should be updated + assert "2.0" in widget.toggle_btn.text() or "2.00" in widget.toggle_btn.text() + + +# ============================================================================ +# TimeLogDialog Tests +# ============================================================================ + + +def test_time_log_dialog_creation(qtbot, fresh_db): + """TimeLogDialog can be created.""" + strings.load_strings("en") + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert _today() in dialog.windowTitle() + assert dialog.project_combo.count() == 0 + assert dialog.table.rowCount() == 0 + + +def test_time_log_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads existing projects into combo.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 2 + + +def test_time_log_dialog_loads_activities_for_autocomplete(qtbot, fresh_db): + """Dialog loads activities for autocomplete.""" + fresh_db.add_activity("Coding") + fresh_db.add_activity("Testing") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + completer = dialog.activity_edit.completer() + assert completer is not None + + +def test_time_log_dialog_loads_existing_entries(qtbot, fresh_db): + """Dialog loads existing time log entries.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.table.rowCount() == 1 + assert "Project" in dialog.table.item(0, 0).text() + assert "Activity" in dialog.table.item(0, 1).text() + assert ( + "1.5" in dialog.table.item(0, 3).text() + or "1.50" in dialog.table.item(0, 3).text() + ) + + +def test_time_log_dialog_add_entry_without_project_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Add entry without selecting project shows warning.""" + strings.load_strings("en") + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.activity_edit.setText("Some Activity") + dialog.hours_spin.setValue(1.0) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._on_add_or_update() + assert warning_shown["shown"] + + +def test_time_log_dialog_add_entry_without_activity_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Add entry without activity shows warning.""" + strings.load_strings("en") + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.hours_spin.setValue(1.0) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._on_add_or_update() + assert warning_shown["shown"] + + +def test_time_log_dialog_add_entry_success(qtbot, fresh_db): + """Successfully add a new time entry.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("New Activity") + dialog.hours_spin.setValue(2.5) + + dialog._on_add_or_update() + + assert dialog.table.rowCount() == 1 + assert "New Activity" in dialog.table.item(0, 1).text() + assert ( + "2.5" in dialog.table.item(0, 3).text() + or "2.50" in dialog.table.item(0, 3).text() + ) + + +def test_time_log_dialog_select_row_enables_delete(qtbot, fresh_db): + """Selecting a row enables delete button and populates fields.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Initially delete is disabled + assert not dialog.delete_btn.isEnabled() + + # Select first row + dialog.table.selectRow(0) + + # Delete should be enabled + assert dialog.delete_btn.isEnabled() + + # Fields should be populated + assert dialog.activity_edit.text() == "Activity" + assert dialog.hours_spin.value() == 1.5 + + +def test_time_log_dialog_update_entry(qtbot, fresh_db): + """Update an existing entry.""" + proj1_id = fresh_db.add_project("Project 1") + fresh_db.add_project("Project 2") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select and modify + dialog.table.selectRow(0) + dialog.project_combo.setCurrentIndex(1) # Project 2 + dialog.hours_spin.setValue(2.0) + + dialog._on_add_or_update() + + # Should still have 1 row (updated, not added) + assert dialog.table.rowCount() == 1 + assert "Project 2" in dialog.table.item(0, 0).text() + assert ( + "2.0" in dialog.table.item(0, 3).text() + or "2.00" in dialog.table.item(0, 3).text() + ) + + +def test_time_log_dialog_delete_entry(qtbot, fresh_db): + """Delete a time log entry.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.table.rowCount() == 1 + + dialog.table.selectRow(0) + dialog._on_delete_entry() + + assert dialog.table.rowCount() == 0 + + +def test_time_log_dialog_manage_projects_opens_dialog(qtbot, fresh_db, monkeypatch): + """Manage projects button opens TimeCodeManagerDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + manager_shown = {"shown": False} + + def mock_exec(self): + manager_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec) + + dialog.manage_projects_btn.click() + assert manager_shown["shown"] + + +def test_time_log_dialog_manage_activities_opens_dialog(qtbot, fresh_db, monkeypatch): + """Manage activities button opens TimeCodeManagerDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + manager_shown = {"shown": False} + + def mock_exec(self): + manager_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec) + + dialog.manage_activities_btn.click() + assert manager_shown["shown"] + + +def test_time_log_dialog_run_report_opens_dialog(qtbot, fresh_db, monkeypatch): + """Run report button opens TimeReportDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + report_shown = {"shown": False} + + def mock_exec(self): + report_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeReportDialog, "exec", mock_exec) + + dialog.report_btn.click() + assert report_shown["shown"] + + +# ============================================================================ +# TimeCodeManagerDialog Tests +# ============================================================================ + + +def test_time_code_manager_dialog_creation(qtbot, fresh_db): + """TimeCodeManagerDialog can be created.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.tabs.count() == 2 + assert dialog.tabs.currentIndex() == 0 # Projects tab + + +def test_time_code_manager_dialog_focus_activities_tab(qtbot, fresh_db): + """Can focus on activities tab initially.""" + dialog = TimeCodeManagerDialog(fresh_db, focus_tab="activities") + qtbot.addWidget(dialog) + + assert dialog.tabs.currentIndex() == 1 + + +def test_time_code_manager_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads existing projects.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_list.count() == 2 + + +def test_time_code_manager_dialog_loads_activities(qtbot, fresh_db): + """Dialog loads existing activities.""" + fresh_db.add_activity("Activity 1") + fresh_db.add_activity("Activity 2") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.activity_list.count() == 2 + + +def test_time_code_manager_add_project(qtbot, fresh_db, monkeypatch): + """Add a new project.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "New Project", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_project() + + assert dialog.project_list.count() == 1 + assert dialog.project_list.item(0).text() == "New Project" + + +def test_time_code_manager_add_project_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel adding project.""" + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_project() + + assert dialog.project_list.count() == 0 + + +def test_time_code_manager_add_project_invalid_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Adding invalid project shows warning.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "Valid Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + # Force add_project to raise ValueError + original_add = fresh_db.add_project + + def bad_add(name): + raise ValueError("empty project name") + + fresh_db.add_project = bad_add + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._add_project() + assert warning_shown["shown"] + + fresh_db.add_project = original_add + + +def test_time_code_manager_rename_project(qtbot, fresh_db, monkeypatch): + """Rename an existing project.""" + strings.load_strings("en") + fresh_db.add_project("Old Name") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "New Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + assert dialog.project_list.item(0).text() == "New Name" + + +def test_time_code_manager_rename_project_no_selection_shows_info( + qtbot, fresh_db, monkeypatch +): + """Rename without selection shows info message.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._rename_project() + assert info_shown["shown"] + + +def test_time_code_manager_rename_project_to_duplicate_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Renaming to duplicate name shows warning.""" + strings.load_strings("en") + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(1) + + def mock_get_text(parent, title, label, mode, default): + return "Project A", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._rename_project() + assert warning_shown["shown"] + + +def test_time_code_manager_delete_project(qtbot, fresh_db, monkeypatch): + """Delete a project.""" + strings.load_strings("en") + fresh_db.add_project("To Delete") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_project() + + assert dialog.project_list.count() == 0 + + +def test_time_code_manager_delete_project_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel delete project.""" + strings.load_strings("en") + fresh_db.add_project("Keep Me") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_project() + + assert dialog.project_list.count() == 1 + + +def test_time_code_manager_delete_project_with_entries_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Deleting project with entries shows warning.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Used Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._delete_project() + assert warning_shown["shown"] + assert dialog.project_list.count() == 1 # Not deleted + + +def test_time_code_manager_add_activity(qtbot, fresh_db, monkeypatch): + """Add a new activity.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "New Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_activity() + + assert dialog.activity_list.count() == 1 + assert dialog.activity_list.item(0).text() == "New Activity" + + +def test_time_code_manager_rename_activity(qtbot, fresh_db, monkeypatch): + """Rename an existing activity.""" + strings.load_strings("en") + fresh_db.add_activity("Old Activity") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "New Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + assert dialog.activity_list.item(0).text() == "New Activity" + + +def test_time_code_manager_delete_activity(qtbot, fresh_db, monkeypatch): + """Delete an activity.""" + strings.load_strings("en") + fresh_db.add_activity("To Delete") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_activity() + + assert dialog.activity_list.count() == 0 + + +def test_time_code_manager_delete_activity_with_entries_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Deleting activity with entries shows warning.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Used Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._delete_activity() + assert warning_shown["shown"] + assert dialog.activity_list.count() == 1 # Not deleted + + +# ============================================================================ +# TimeReportDialog Tests +# ============================================================================ + + +def test_time_report_dialog_creation(qtbot, fresh_db): + """TimeReportDialog can be created.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 0 + assert dialog.granularity.count() == 3 # day, week, month + + +def test_time_report_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads projects.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 2 + + +def test_time_report_dialog_default_date_range(qtbot, fresh_db): + """Dialog defaults to last 7 days.""" + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + today = QDate.currentDate() + week_ago = today.addDays(-7) + + assert dialog.from_date.date() == week_ago + assert dialog.to_date.date() == today + + +def test_time_report_dialog_run_report(qtbot, fresh_db): + """Run a time report.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(0) # day + + dialog._run_report() + + assert dialog.table.rowCount() == 1 + assert "Activity" in dialog.table.item(0, 1).text() + assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text() + + +def test_time_report_dialog_run_report_no_project_selected(qtbot, fresh_db): + """Run report with no project selected does nothing.""" + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog._run_report() + + # Should not crash, table remains empty + assert dialog.table.rowCount() == 0 + + +def test_time_report_dialog_export_csv_no_report_shows_info( + qtbot, fresh_db, monkeypatch +): + """Export CSV without running report shows info.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._export_csv() + assert info_shown["shown"] + + +def test_time_report_dialog_export_csv_success(qtbot, fresh_db, tmp_path, monkeypatch): + """Export report to CSV.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + csv_file = str(tmp_path / "report.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + dialog._export_csv() + + import os + + assert os.path.exists(csv_file) + + with open(csv_file, "r") as f: + content = f.read() + assert "Activity" in content + assert "2.00" in content + + +def test_time_report_dialog_export_csv_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel CSV export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + def mock_get_save_filename(*args, **kwargs): + return "", "" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + # Should not crash + dialog._export_csv() + + +def test_time_report_dialog_export_pdf_no_report_shows_info( + qtbot, fresh_db, monkeypatch +): + """Export PDF without running report shows info.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._export_pdf() + assert info_shown["shown"] + + +def test_time_report_dialog_export_pdf_success(qtbot, fresh_db, tmp_path, monkeypatch): + """Export report to PDF.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Test Project") + act_id = fresh_db.add_activity("Testing") + fresh_db.add_time_log(_today(), proj_id, act_id, 150) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "report.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + # PDF should have content + assert os.path.getsize(pdf_file) > 0 + + +def test_time_report_dialog_export_pdf_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel PDF export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + def mock_get_save_filename(*args, **kwargs): + return "", "" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + # Should not crash + dialog._export_pdf() + + +def test_time_report_dialog_granularity_week(qtbot, fresh_db): + """Report with week granularity.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different days of the same week + date1 = "2024-01-01" + date2 = "2024-01-03" + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(1) # week + + dialog._run_report() + + # Should aggregate to single week + assert dialog.table.rowCount() == 1 + hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text + + +def test_time_report_dialog_granularity_month(qtbot, fresh_db): + """Report with month granularity.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different days of the same month + date1 = "2024-01-05" + date2 = "2024-01-25" + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(2) # month + + dialog._run_report() + + # Should aggregate to single month + assert dialog.table.rowCount() == 1 + hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text + + +def test_time_report_dialog_multiple_activities_same_period(qtbot, fresh_db): + """Report shows multiple activities separately.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + fresh_db.add_time_log(_today(), proj_id, act1_id, 60) + fresh_db.add_time_log(_today(), proj_id, act2_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + assert dialog.table.rowCount() == 2 + + +def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db): + """Widget shows per-project breakdown in summary.""" + strings.load_strings("en") + proj1_id = fresh_db.add_project("Project A") + proj2_id = fresh_db.add_project("Project B") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + fresh_db.add_time_log(_today(), proj2_id, act_id, 90) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + summary = widget.summary_label.text() + assert "Project A" in summary + assert "Project B" in summary + assert "1.00h" in summary + assert "1.50h" in summary + + +def test_time_report_dialog_csv_export_handles_os_error( + qtbot, fresh_db, tmp_path, monkeypatch +): + """CSV export handles OSError gracefully.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + # Use a path that will cause an error (e.g., directory instead of file) + bad_path = str(tmp_path) + + def mock_get_save_filename(*args, **kwargs): + return bad_path, "CSV Files (*.csv)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._export_csv() + assert warning_shown["shown"] + + +# ============================================================================ +# Additional TimeLogWidget Edge Cases +# ============================================================================ + + +def test_time_log_widget_collapsed_shows_hint_after_date_set(qtbot, fresh_db): + """When collapsed, setting date shows hint instead of full summary.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Keep collapsed + assert not widget.toggle_btn.isChecked() + + widget.set_current_date(_today()) + + # Should show hint, not full breakdown + assert ( + "hint" in widget.summary_label.text().lower() + or "time log" in widget.summary_label.text().lower() + ) + + +def test_time_log_widget_no_date_shows_no_date_message(qtbot, fresh_db): + """Widget with no date set shows appropriate message.""" + strings.load_strings("en") + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Expand to see summary + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Don't set a date + widget._reload_summary() + + # Should indicate no date is set + summary = widget.summary_label.text() + assert "no date" in summary.lower() or "time log" in summary.lower() + + +def test_time_log_widget_header_updates_on_toggle(qtbot, fresh_db): + """Header total is visible even when collapsed.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) # 2 hours + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + # Expand to trigger reload + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Header should show total + assert "2" in widget.toggle_btn.text() + + # Collapse again + widget.toggle_btn.setChecked(False) + widget._on_toggle(False) + + # Header total should still be visible + assert "2" in widget.toggle_btn.text() + + +def test_time_log_widget_dialog_updates_on_close_when_collapsed( + qtbot, fresh_db, monkeypatch +): + """When dialog closes, widget updates summary even if collapsed.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + # Keep collapsed + assert not widget.toggle_btn.isChecked() + + def mock_exec(self): + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + + # Should show hint after update when collapsed + assert ( + "hint" in widget.summary_label.text().lower() + or "time log" in widget.summary_label.text().lower() + ) + + +# ============================================================================ +# Additional TimeLogDialog Edge Cases +# ============================================================================ + + +def test_time_log_dialog_deselect_clears_current_entry(qtbot, fresh_db): + """Deselecting row clears current entry and disables delete.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select + dialog.table.selectRow(0) + assert dialog.delete_btn.isEnabled() + + # Clear selection + dialog.table.clearSelection() + + # Trigger selection changed + dialog._on_row_selected() + + assert not dialog.delete_btn.isEnabled() + assert dialog._current_entry_id is None + + +def test_time_log_dialog_delete_without_selection_does_nothing(qtbot, fresh_db): + """Delete button when no entry selected does nothing.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # No selection + dialog._current_entry_id = None + + # Should not crash + dialog._on_delete_entry() + + +def test_time_log_dialog_creates_activity_if_new(qtbot, fresh_db): + """Dialog creates activity if it doesn't exist.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Brand New Activity") + dialog.hours_spin.setValue(1.0) + + dialog._on_add_or_update() + + # Activity should have been created + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0][1] == "Brand New Activity" + + +def test_time_log_dialog_rounds_hours_to_minutes(qtbot, fresh_db): + """Hours are correctly converted to integer minutes.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Activity") + dialog.hours_spin.setValue(1.75) # 1 hour 45 minutes = 105 minutes + + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + assert entries[0][6] == 105 # minutes + + +def test_time_log_dialog_update_button_text_changes(qtbot, fresh_db): + """Button text changes between Add and Update.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Initially "Add" + assert "add" in dialog.add_update_btn.text().lower() + + # Select entry + dialog.table.selectRow(0) + + # Should change to "Update" + assert "update" in dialog.add_update_btn.text().lower() + + # Deselect + dialog.table.clearSelection() + dialog._on_row_selected() + + # Back to "Add" + assert "add" in dialog.add_update_btn.text().lower() + + +def test_time_log_dialog_project_selection_by_name(qtbot, fresh_db): + """Selecting entry sets project combo by name match.""" + fresh_db.add_project("Project A") + proj2_id = fresh_db.add_project("Project B") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj2_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.table.selectRow(0) + + # Should select Project B + assert dialog.project_combo.currentText() == "Project B" + + +def test_time_log_dialog_project_not_found_in_combo(qtbot, fresh_db): + """If project name not found in combo, selection doesn't crash.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + # Delete the project from DB manually (shouldn't happen, but defensive) + fresh_db.delete_time_log(entry_id) + fresh_db.delete_project(proj_id) + + # Re-add entry with orphaned project reference (simulated edge case) + # Actually can't easily simulate this due to FK constraints + # So just test normal case - combo should work fine + proj_id = fresh_db.add_project("Project") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.table.selectRow(0) + # Should not crash + + +# ============================================================================ +# Additional TimeCodeManagerDialog Edge Cases +# ============================================================================ + + +def test_time_code_manager_rename_cancelled_does_nothing(qtbot, fresh_db, monkeypatch): + """Cancelling rename does nothing.""" + strings.load_strings("en") + fresh_db.add_project("Original") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + # Name should remain unchanged + assert dialog.project_list.item(0).text() == "Original" + + +def test_time_code_manager_rename_to_same_name_does_nothing( + qtbot, fresh_db, monkeypatch +): + """Renaming to same name does nothing.""" + strings.load_strings("en") + fresh_db.add_project("Same Name") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "Same Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + # Should still have one project + assert dialog.project_list.count() == 1 + + +def test_time_code_manager_delete_no_selection_shows_info(qtbot, fresh_db, monkeypatch): + """Delete without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._delete_project() + assert info_shown["shown"] + + +def test_time_code_manager_add_empty_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Adding empty activity name is cancelled.""" + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "", True # Empty but OK clicked + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_activity() + + # No activity should be added + assert dialog.activity_list.count() == 0 + + +def test_time_code_manager_rename_activity_no_selection(qtbot, fresh_db, monkeypatch): + """Rename activity without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._rename_activity() + assert info_shown["shown"] + + +def test_time_code_manager_delete_activity_no_selection(qtbot, fresh_db, monkeypatch): + """Delete activity without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._delete_activity() + assert info_shown["shown"] + + +def test_time_code_manager_delete_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Cancelling delete activity keeps it.""" + strings.load_strings("en") + fresh_db.add_activity("Keep Me") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_activity() + + assert dialog.activity_list.count() == 1 + + +# ============================================================================ +# Additional TimeReportDialog Edge Cases +# ============================================================================ + + +def test_time_report_dialog_empty_report_shows_zero(qtbot, fresh_db): + """Running report with no data shows zero total.""" + strings.load_strings("en") + fresh_db.add_project("Project") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + assert dialog.table.rowCount() == 0 + assert "0" in dialog.total_label.text() + + +def test_time_report_dialog_date_range_filters_correctly(qtbot, fresh_db): + """Report only includes entries within date range.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different dates + date1 = "2024-01-01" + date2 = "2024-01-15" + date3 = "2024-01-30" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 60) + fresh_db.add_time_log(date3, proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + # Set range to only include middle date + dialog.from_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + + dialog._run_report() + + # Should only have one entry + assert dialog.table.rowCount() == 1 + + +def test_time_report_dialog_stores_report_state(qtbot, fresh_db): + """Dialog stores last report state for export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("My Project") + act_id = fresh_db.add_activity("My Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(1) # week + + dialog._run_report() + + # Check stored state + assert dialog._last_project_name == "My Project" + assert dialog._last_start == _today() + assert dialog._last_end == _today() + assert "week" in dialog._last_gran_label.lower() + assert len(dialog._last_rows) == 1 + assert dialog._last_total_minutes == 90 + + +def test_time_report_dialog_pdf_export_with_multiple_periods( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF export handles multiple time periods with chart.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on multiple days + date1 = "2024-01-01" + date2 = "2024-01-02" + date3 = "2024-01-03" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(0) # day + + dialog._run_report() + + pdf_file = str(tmp_path / "chart_test.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + assert os.path.getsize(pdf_file) > 0 + + +def test_time_report_dialog_pdf_export_with_zero_hours( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF export handles entries with zero hours gracefully.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with 0 minutes (edge case) + fresh_db.add_time_log(_today(), proj_id, act_id, 0) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "zero_hours.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Should not crash + dialog._export_pdf() + + +def test_time_report_dialog_csv_includes_total_row( + qtbot, fresh_db, tmp_path, monkeypatch +): + """CSV export includes total row at bottom.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + csv_file = str(tmp_path / "total_test.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_csv() + + with open(csv_file, "r") as f: + lines = f.readlines() + # Should have header, data row, blank, total row + assert len(lines) >= 4 + # Last line should contain total + assert "Total" in lines[-1] or "total" in lines[-1] + + +def test_time_report_dialog_pdf_chart_with_single_period( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF chart renders correctly with single period.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "single_period.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +def test_full_workflow_add_project_activity_log_report( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test full workflow: create project, activity, log time, run report.""" + strings.load_strings("en") + + # 1. Create project via dialog + manager = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(manager) + + def mock_get_text_project(parent, title, label, mode, default): + return "Test Project", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text_project) + manager._add_project() + + # 2. Create activity via dialog + def mock_get_text_activity(parent, title, label, mode, default): + return "Test Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text_activity) + manager._add_activity() + + manager.accept() + + # 3. Log time via dialog + log_dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(log_dialog) + + log_dialog.project_combo.setCurrentIndex(0) + log_dialog.activity_edit.setText("Test Activity") + log_dialog.hours_spin.setValue(2.5) + + log_dialog._on_add_or_update() + log_dialog.accept() + + # 4. Run report + report_dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(report_dialog) + + report_dialog.project_combo.setCurrentIndex(0) + report_dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + report_dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + report_dialog._run_report() + + # Verify report + assert report_dialog.table.rowCount() == 1 + assert "Test Activity" in report_dialog.table.item(0, 1).text() + assert ( + "2.5" in report_dialog.table.item(0, 3).text() + or "2.50" in report_dialog.table.item(0, 3).text() + ) + + # 5. Export CSV + csv_file = str(tmp_path / "workflow_test.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + report_dialog._export_csv() + + import os + + assert os.path.exists(csv_file) + + +def test_time_log_widget_with_multiple_projects_same_day(qtbot, fresh_db): + """Widget correctly aggregates multiple projects on same day.""" + strings.load_strings("en") + proj1_id = fresh_db.add_project("Alpha") + proj2_id = fresh_db.add_project("Beta") + proj3_id = fresh_db.add_project("Gamma") + act_id = fresh_db.add_activity("Work") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 30) + fresh_db.add_time_log(_today(), proj2_id, act_id, 45) + fresh_db.add_time_log(_today(), proj3_id, act_id, 60) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + # Total should be 2.25 hours + title = widget.toggle_btn.text() + assert "2.25" in title or "2.2" in title + + # Summary should list all three projects + summary = widget.summary_label.text() + assert "Alpha" in summary + assert "Beta" in summary + assert "Gamma" in summary + + +def test_time_log_dialog_preserves_entry_id_through_update(qtbot, fresh_db): + """Updating entry preserves entry ID.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select and update + dialog.table.selectRow(0) + original_id = dialog._current_entry_id + + dialog.hours_spin.setValue(2.0) + dialog._on_add_or_update() + + # Should still have same number of entries + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + assert entries[0][0] == original_id + + +def test_db_time_log_sorting_by_project_activity_id(fresh_db): + """time_log_for_date returns entries sorted correctly.""" + # Create in specific order to test sorting + proj_z = fresh_db.add_project("ZZZ") + proj_a = fresh_db.add_project("AAA") + act_y = fresh_db.add_activity("YYY") + act_x = fresh_db.add_activity("XXX") + + # Add in reverse alphabetical order + fresh_db.add_time_log(_today(), proj_z, act_y, 10) + fresh_db.add_time_log(_today(), proj_z, act_x, 20) + fresh_db.add_time_log(_today(), proj_a, act_y, 30) + fresh_db.add_time_log(_today(), proj_a, act_x, 40) + + entries = fresh_db.time_log_for_date(_today()) + + # Should be sorted: AAA/XXX, AAA/YYY, ZZZ/XXX, ZZZ/YYY + assert entries[0][3] == "AAA" and entries[0][5] == "XXX" + assert entries[1][3] == "AAA" and entries[1][5] == "YYY" + assert entries[2][3] == "ZZZ" and entries[2][5] == "XXX" + assert entries[3][3] == "ZZZ" and entries[3][5] == "YYY" + + +def test_time_code_manager_add_activity_invalid_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Test adding invalid activity shows warning.""" + strings.load_strings("en") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + def mock_get_text(parent, title, label, mode, default): + return "Valid Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + # Force add_activity to raise ValueError + original_add = fresh_db.add_activity + + def bad_add(name): + raise ValueError("empty activity name") + + fresh_db.add_activity = bad_add + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._add_activity() + + assert warning_shown["shown"] + + fresh_db.add_activity = original_add + + +def test_time_code_manager_rename_activity_to_duplicate(qtbot, fresh_db, monkeypatch): + """Test renaming activity to existing name shows warning.""" + strings.load_strings("en") + + fresh_db.add_activity("Activity1") + fresh_db.add_activity("Activity2") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "Activity2", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._rename_activity() + + assert warning_shown["shown"] + + +def test_time_code_manager_rename_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Test cancelling activity rename.""" + strings.load_strings("en") + + fresh_db.add_activity("Original") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + # Should remain unchanged + assert dialog.activity_list.item(0).text() == "Original" + + +def test_time_code_manager_rename_activity_same_name(qtbot, fresh_db, monkeypatch): + """Test renaming activity to same name does nothing.""" + strings.load_strings("en") + + fresh_db.add_activity("SameName") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "SameName", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + # Should still have one activity + assert dialog.activity_list.count() == 1 + + +def test_time_report_dialog_pdf_export_error_handling( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test PDF export handles exceptions gracefully.""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "test.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Mock QTextDocument.print_ to raise exception + from PySide6.QtGui import QTextDocument + + original_print = QTextDocument.print_ + + def bad_print(self, printer): + raise Exception("Print error") + + monkeypatch.setattr(QTextDocument, "print_", bad_print) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._export_pdf() + + # Should show warning + assert warning_shown["shown"] + + monkeypatch.setattr(QTextDocument, "print_", original_print) + + +def test_time_log_dialog_hours_conversion_edge_cases(qtbot, fresh_db): + """Test edge cases in hours to minutes conversion.""" + strings.load_strings("en") + + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Activity") + + # Test 0 hours + dialog.hours_spin.setValue(0.0) + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + assert entries[-1][6] == 0 + + # Test fractional hours that round + dialog.hours_spin.setValue(0.333) # ~20 minutes + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + # Should round to nearest minute + assert 19 <= entries[-1][6] <= 21 + + +def test_time_report_dialog_pdf_with_no_activity_data( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test PDF export with report that has no data in bars (0 minutes).""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with 0 minutes + fresh_db.add_time_log(_today(), proj_id, act_id, 0) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "zero_data.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Should handle zero data without crashing + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + + +def test_time_report_dialog_very_large_hours(qtbot, fresh_db): + """Test handling very large hour values.""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with many hours + large_minutes = 10000 # ~166 hours + fresh_db.add_time_log(_today(), proj_id, act_id, large_minutes) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + # Should handle large values + dialog._run_report() + + # Check total label + assert "166" in dialog.total_label.text() or "167" in dialog.total_label.text() + + +def test_time_log_widget_creation(qtbot, fresh_db): + """TimeLogWidget can be created.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + assert widget is not None + assert not widget.toggle_btn.isChecked() + assert not widget.body.isVisible() + + +def test_time_log_set_current_date(qtbot, fresh_db): + """Test setting the current date on the time log widget.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + today = date.today().isoformat() + widget.set_current_date(today) + + # Verify the current date was set + assert widget._current_date == today + + +def test_time_log_with_entry(qtbot, fresh_db): + """Test time log widget with a time entry.""" + # Add a project + proj_id = fresh_db.add_project("Test Project") + # Add activity + act_id = fresh_db.add_activity("Test Activity") + + # Add a time log entry + today = date.today().isoformat() + fresh_db.add_time_log( + date_iso=today, + project_id=proj_id, + activity_id=act_id, + minutes=150, + note="Test note", + ) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Set the date to today + widget.set_current_date(today) + + # Widget should have been created successfully + assert widget is not None diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py new file mode 100644 index 0000000..3794760 --- /dev/null +++ b/tests/test_toolbar.py @@ -0,0 +1,67 @@ +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 +def editor(app, qtbot): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + ed = MarkdownEditor(themes) + qtbot.addWidget(ed) + ed.show() + return ed + + +def test_toolbar_signals_and_styling(qtbot, editor): + host = QWidget() + qtbot.addWidget(host) + host.show() + + tb = ToolBar(parent=host) + qtbot.addWidget(tb) + tb.show() + + tb.boldRequested.connect(editor.apply_weight) + tb.italicRequested.connect(editor.apply_italic) + tb.strikeRequested.connect(editor.apply_strikethrough) + tb.codeRequested.connect(lambda: editor.apply_code()) + tb.headingRequested.connect(lambda s: editor.apply_heading(s)) + tb.bulletsRequested.connect(lambda: editor.toggle_bullets()) + tb.numbersRequested.connect(lambda: editor.toggle_numbers()) + tb.checkboxesRequested.connect(lambda: editor.toggle_checkboxes()) + + editor.from_markdown("hello") + editor.selectAll() + tb.boldRequested.emit() + tb.italicRequested.emit() + 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" diff --git a/tests/test_toolbar_private.py b/tests/test_toolbar_private.py deleted file mode 100644 index 834f4c2..0000000 --- a/tests/test_toolbar_private.py +++ /dev/null @@ -1,23 +0,0 @@ -from bouquin.toolbar import ToolBar - - -def test_style_letter_button_handles_missing_widget(qtbot): - tb = ToolBar() - qtbot.addWidget(tb) - # Create a dummy action detached from toolbar to force widgetForAction->None - from PySide6.QtGui import QAction - - act = QAction("X", tb) - # No crash and early return - tb._style_letter_button(act, "X") - - -def test_style_letter_button_sets_tooltip_and_accessible(qtbot): - tb = ToolBar() - qtbot.addWidget(tb) - # Use an existing action so widgetForAction returns a button - act = tb.actBold - tb._style_letter_button(act, "B", bold=True, tooltip="Bold") - btn = tb.widgetForAction(act) - assert btn.toolTip() == "Bold" - assert btn.accessibleName() == "Bold" diff --git a/tests/test_toolbar_styles.py b/tests/test_toolbar_styles.py deleted file mode 100644 index 7116d21..0000000 --- a/tests/test_toolbar_styles.py +++ /dev/null @@ -1,55 +0,0 @@ -from PySide6.QtGui import QTextCursor, QFont -from PySide6.QtCore import Qt -from PySide6.QtTest import QTest - - -def test_toggle_basic_char_styles(open_window, qtbot): - win = open_window - win.editor.setPlainText("style") - c = win.editor.textCursor() - c.select(QTextCursor.Document) - win.editor.setTextCursor(c) - win.toolBar.actBold.trigger() - assert win.editor.currentCharFormat().fontWeight() == QFont.Weight.Bold - win.toolBar.actItalic.trigger() - assert win.editor.currentCharFormat().fontItalic() is True - win.toolBar.actUnderline.trigger() - assert win.editor.currentCharFormat().fontUnderline() is True - win.toolBar.actStrike.trigger() - assert win.editor.currentCharFormat().fontStrikeOut() is True - - -def test_headings_lists_and_alignment(open_window, qtbot): - win = open_window - win.editor.setPlainText("Heading\nSecond line") - c = win.editor.textCursor() - c.select(QTextCursor.LineUnderCursor) - win.editor.setTextCursor(c) - - sizes = [] - for attr in ("actH1", "actH2", "actH3"): - if hasattr(win.toolBar, attr): - getattr(win.toolBar, attr).trigger() - QTest.qWait(45) # let the format settle to avoid segfaults on some styles - sizes.append(win.editor.currentCharFormat().fontPointSize()) - assert len(sizes) >= 2 and all( - a > b for a, b in zip(sizes, sizes[1:]) - ), f"Heading sizes not decreasing: {sizes}" - - win.toolBar.actCode.trigger() - QTest.qWait(45) - - win.toolBar.actBullets.trigger() - QTest.qWait(45) - win.toolBar.actNumbers.trigger() - QTest.qWait(45) - - win.toolBar.actAlignC.trigger() - QTest.qWait(45) - assert int(win.editor.alignment()) & int(Qt.AlignHCenter) - win.toolBar.actAlignR.trigger() - QTest.qWait(45) - assert int(win.editor.alignment()) & int(Qt.AlignRight) - win.toolBar.actAlignL.trigger() - QTest.qWait(45) - assert int(win.editor.alignment()) & int(Qt.AlignLeft) diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..b5afe12 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,534 @@ +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 diff --git a/vulture_ignorelist.py b/vulture_ignorelist.py new file mode 100644 index 0000000..2addc55 --- /dev/null +++ b/vulture_ignorelist.py @@ -0,0 +1,26 @@ +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