diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index 87b67ff..0000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: CI - -on: - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \ - libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \ - libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \ - libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \ - libxkbcommon0 - - - name: Install Poetry - run: | - pipx install poetry==1.8.3 - /root/.local/bin/poetry --version - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install project deps (including test extras) - run: | - poetry install --with test - - - name: Run test script - run: | - ./tests.sh - diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml deleted file mode 100644 index 5bb3794..0000000 --- a/.forgejo/workflows/lint.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Lint - -on: - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 vulture python3-bandit - - - name: Run linters - run: | - black --diff --check bouquin/* - black --diff --check tests/* - pyflakes3 bouquin/* - pyflakes3 tests/* - vulture - bandit -s B110 -r bouquin/ diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml deleted file mode 100644 index 18ced32..0000000 --- a/.forgejo/workflows/trivy.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Trivy - -on: - schedule: - - cron: '0 1 * * *' - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null - echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy - - - name: Run trivy - run: | - trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . diff --git a/.gitignore b/.gitignore index 851b242..2352872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ __pycache__ .pytest_cache dist .coverage -*.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 7839225..d2024eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,155 +1,3 @@ -# 0.5.2 - - * Update icon again to remove background - * Adjust History icon and reorder toolbar items - * Try to address checkbox/bullet size issues (again) - * Fix HTML export of markdown (with newlines, tables and other styling preserved) - * Remove table tool - -# 0.5.1 - - * Try to address Noto Sans font issue that works for both numbers and checkbox/bullets. - * Update icon - * Update French translations - * Improve size of flashing reminder dialog - -# 0.5 - - * More Italian translations, thank you @mdaleo404 - * Set locked status on window title when locked - * Don't exit on incorrect key, let it be tried again - * Make reminders be its own dataset rather than tied to current string. - * Add support for repeated reminders - * Make reminders be a feature that can be turned on and off - * Add syntax highlighting for code blocks (right-click to set it) - * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log) - * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog - -# 0.4.5 - - * Make it possible to delete revisions - * Make it possible to force-lock the screen even if idle timer hasn't tripped - * Add shortcuts for lock and unlock of screen - * Other misc bug fixes - -# 0.4.4.1 - - * Adjust some widget heights/settings text wrap - * Adjust shortcuts - * History unicode symbol - * Icon in version dialog - -# 0.4.4 - - * Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday - * Moving unchecked TODOs now skips placing them on weekends. - * Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup) - * Check for new version / download new AppImage via the Help -> Version screen. - * Remove extra newline after headings - -# 0.4.3 - - * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora - -# 0.4.2 - - * Improve Statistics widget height - * Improve SaveDialog widget width - * Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default) - * Make it possible to change regular text size - * Refactored Settings dialog to use tabs to reduce its size - -# 0.4.1 - - * Allow time log entries to be edited directly in their table cells - * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) - * Add 'Close tab' nav item and shortcut - -# 0.4 - - * Remove screenshot tool - * Improve width of bug report dialog - * Improve size of checkboxes - * Convert bullet - to actual unicode bullets - * Add alarm option to set reminders - * Add time logging and reporting - -# 0.3.2 - - * Add weekday letters on left axis of Statistics page - * Allow clicking on a date in the Statistics heatmap and have it open that page - * Add the ability to choose the database path at startup - * Add in-app bug report functionality - -# 0.3.1 - - * Make it possible to add a tag from the Tag Browser - * Add a statistics dialog with heatmap - * Remove export to .txt (just use .md) - * Restore link styling and clickability - -# 0.3 - - * Introduce Tags - * Make translations dynamically detected from the locales dir rather than hardcoded - * Add Italian translations (thanks @mdaleo404) - * Add version information in the navigation - * Increase line spacing between lines (except for code blocks) - * Prevent being able to left-click a date and have it load in current tab if it is already open in another tab - * Avoid second checkbox/bullet on second newline after first newline - * Avoid Home/left arrow jumping to the left side of a list symbol - * Various test additions/fixes - -# 0.2.1.8 - - * Translate all strings, add French, add locale choice in settings - * Fix hiding status bar (including find bar) when locked - -# 0.2.1.7 - - * Fix being able to set bold, italic and strikethrough at the same time. - * Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager - * Add AppImage - -# 0.2.1.6 - - * Some code cleanup and more coverage - * Improve code block styling / escaping out of the block in various scenarios - -# 0.2.1.5 - - * Go back to font size 10 (I might add a switcher later) - * Fix bug with not syncing the right calendar date on search (History item would then be wrong too) - -# 0.2.1.4 - - * Increase font size of normal text - * Fix auto-save of a tab if we are moving to another tab and it has not yet saved - * DRY up some code - -# 0.2.1.3 - - * Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line - * Fix code backticks to not show but still be able to type code easily - -# 0.2.1.2 - - * Ensure tabs are ordered by calendar date - * Some other code cleanups - -# 0.2.1.1 - - * Fix history preview pane to be in markdown - * Some other code cleanups - -# 0.2.1 - - * Introduce tabs! - -# 0.2.0.1 - - * Fix chomping images when TODO is typed and converts to a checkbox - # 0.2.0 * Switch back to Markdown editor diff --git a/README.md b/README.md index 5cf77e5..2cc03ef 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,9 @@ # Bouquin -
- Bouquin logo -
## Introduction -Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher. - -It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging, -search, reminders and time logging for those of us who need to keep track of not just TODOs, but -also how long we spent on them. +Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher. It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement for SQLite3. This means that the underlying database for the notebook is encrypted at rest. @@ -18,78 +11,42 @@ for SQLite3. This means that the underlying database for the notebook is encrypt To increase security, the SQLCipher key is requested when the app is opened, and is not written to disk unless the user configures it to be in the settings. -There is deliberately no network connectivity or syncing intended, other than the option to send a bug -report from within the app, or optionally to check for new versions to upgrade to. +There is deliberately no network connectivity or syncing intended. -## Screenshots +## Screenshot -### General view -
- Bouquin screenshot -
+![Screenshot of Bouquin](./screenshot.png) -### History panes -
- Screenshot of Bouquin History Preview Pane - Screenshot of Bouquin History Diff Pane -
+![Screenshot of Bouquin in dark mode](./screenshot_dark.png) -### Tags -
- Screenshot of Bouquin Tag Manager screen -
- -### Time Logging -
- Screenshot of Bouquin Time Log screens -
- - -### Statistics -
- Bouquin statistics -
- - -## Some of the features +## Features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings - * All changes are version controlled, with ability to view/diff versions, revert or delete revisions - * Automatic rendering of basic Markdown syntax - * Tabs are supported - right-click on a date from the calendar to open it in a new tab. + * Every 'page' is linked to the calendar day + * All changes are version controlled, with ability to view/diff versions and revert + * Text is Markdown with basic styling * Images are supported - * Search all pages, or find text on current page - * Add and manage tags + * Search * Automatic periodic saving (or explicitly save) + * Transparent integrity checking of the database when it opens * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) - * Export the database to json, html, csv, markdown or .sql (for sqlite3) + * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3) * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) - * Dark and light theme support + * Dark and light themes * Automatically generate checkboxes when typing 'TODO' - * It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday. - * English, French and Italian locales provided - * Ability to set reminder alarms (which will be flashed as the reminder) - * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports + * Optionally automatically move unchecked checkboxes from yesterday to today, on startup ## How to install -Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). - -It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes. - -If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). +Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions). ### From PyPi/pip * `pip install bouquin` -### From AppImage - - * Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it. - ### From source * Clone this repo or download the tarball from the releases page diff --git a/bouquin.desktop b/bouquin.desktop deleted file mode 100644 index aa519c3..0000000 --- a/bouquin.desktop +++ /dev/null @@ -1,6 +0,0 @@ -[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 deleted file mode 100644 index 9cc727c..0000000 --- a/bouquin/bug_report_dialog.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -import importlib.metadata - -import requests - -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLabel, - QTextEdit, - QDialogButtonBox, - QMessageBox, -) - -from . import strings - - -BUG_REPORT_HOST = "https://nr.mig5.net" -ROUTE = "forms/bouquin/bugs" - - -class BugReportDialog(QDialog): - """ - Dialog to collect a bug report - """ - - MAX_CHARS = 5000 - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle(strings._("report_a_bug")) - - layout = QVBoxLayout(self) - - header = QLabel(strings._("bug_report_explanation")) - header.setWordWrap(True) - layout.addWidget(header) - - self.text_edit = QTextEdit() - self.text_edit.setPlaceholderText(strings._("bug_report_placeholder")) - layout.addWidget(self.text_edit) - - self.text_edit.textChanged.connect(self._enforce_max_length) - - # Buttons: Cancel / Send - button_box = QDialogButtonBox(QDialogButtonBox.Cancel) - button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole) - button_box.accepted.connect(self._send) - button_box.rejected.connect(self.reject) - layout.addWidget(button_box) - - self.setMinimumWidth(560) - - self.text_edit.setFocus() - - # ------------Helpers ------------ # - - def _enforce_max_length(self): - text = self.text_edit.toPlainText() - if len(text) <= self.MAX_CHARS: - return - - # Remember cursor position - cursor = self.text_edit.textCursor() - pos = cursor.position() - - # Trim and restore without re-entering this slot - self.text_edit.blockSignals(True) - self.text_edit.setPlainText(text[: self.MAX_CHARS]) - self.text_edit.blockSignals(False) - - cursor.setPosition(pos) - self.text_edit.setTextCursor(cursor) - - def _send(self): - text = self.text_edit.toPlainText().strip() - if not text: - QMessageBox.warning( - self, - strings._("report_a_bug"), - strings._("bug_report_empty"), - ) - return - - # Get current app version - version = importlib.metadata.version("bouquin") - - payload: dict[str, str] = { - "message": text, - "version": version, - } - - # POST as JSON - try: - resp = requests.post( - f"{BUG_REPORT_HOST}/{ROUTE}", - json=payload, - timeout=10, - ) - except Exception as e: - QMessageBox.critical( - self, - strings._("report_a_bug"), - strings._("bug_report_send_failed") + f"\n{e}", - ) - return - - if resp.status_code == 201: - QMessageBox.information( - self, - strings._("report_a_bug"), - strings._("bug_report_sent_ok"), - ) - self.accept() - else: - QMessageBox.critical( - self, - strings._("report_a_bug"), - strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})", - ) diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py deleted file mode 100644 index e462574..0000000 --- a/bouquin/code_highlighter.py +++ /dev/null @@ -1,367 +0,0 @@ -from __future__ import annotations - -import re -from typing import Optional, Dict - -from PySide6.QtGui import QColor, QTextCharFormat, QFont - - -class CodeHighlighter: - """Syntax highlighter for different programming languages.""" - - # Language keywords - KEYWORDS = { - "python": [ - "False", - "None", - "True", - "and", - "as", - "assert", - "async", - "await", - "break", - "class", - "continue", - "def", - "del", - "elif", - "else", - "except", - "finally", - "for", - "from", - "global", - "if", - "import", - "in", - "is", - "lambda", - "nonlocal", - "not", - "or", - "pass", - "print", - "raise", - "return", - "try", - "while", - "with", - "yield", - ], - "javascript": [ - "abstract", - "arguments", - "await", - "boolean", - "break", - "byte", - "case", - "catch", - "char", - "class", - "const", - "continue", - "debugger", - "default", - "delete", - "do", - "double", - "else", - "enum", - "eval", - "export", - "extends", - "false", - "final", - "finally", - "float", - "for", - "function", - "goto", - "if", - "implements", - "import", - "in", - "instanceof", - "int", - "interface", - "let", - "long", - "native", - "new", - "null", - "package", - "private", - "protected", - "public", - "return", - "short", - "static", - "super", - "switch", - "synchronized", - "this", - "throw", - "throws", - "transient", - "true", - "try", - "typeof", - "var", - "void", - "volatile", - "while", - "with", - "yield", - ], - "php": [ - "abstract", - "and", - "array", - "as", - "break", - "callable", - "case", - "catch", - "class", - "clone", - "const", - "continue", - "declare", - "default", - "die", - "do", - "echo", - "else", - "elseif", - "empty", - "enddeclare", - "endfor", - "endforeach", - "endif", - "endswitch", - "endwhile", - "eval", - "exit", - "extends", - "final", - "for", - "foreach", - "function", - "global", - "goto", - "if", - "implements", - "include", - "include_once", - "instanceof", - "insteadof", - "interface", - "isset", - "list", - "namespace", - "new", - "or", - "print", - "print_r", - "private", - "protected", - "public", - "require", - "require_once", - "return", - "static", - "syslog", - "switch", - "throw", - "trait", - "try", - "unset", - "use", - "var", - "while", - "xor", - "yield", - ], - "bash": [ - "if", - "then", - "echo", - "else", - "elif", - "fi", - "case", - "esac", - "for", - "select", - "while", - "until", - "do", - "done", - "in", - "function", - "time", - "coproc", - ], - "html": [ - "DOCTYPE", - "html", - "head", - "title", - "meta", - "link", - "style", - "script", - "body", - "div", - "span", - "p", - "a", - "img", - "ul", - "ol", - "li", - "table", - "tr", - "td", - "th", - "form", - "input", - "button", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "br", - "hr", - ], - "css": [ - "color", - "background", - "background-color", - "border", - "margin", - "padding", - "width", - "height", - "font", - "font-size", - "font-weight", - "display", - "position", - "top", - "left", - "right", - "bottom", - "float", - "clear", - "overflow", - "z-index", - "opacity", - ], - } - - @staticmethod - def get_language_patterns(language: str) -> list: - """Get highlighting patterns for a language.""" - patterns = [] - - keywords = CodeHighlighter.KEYWORDS.get(language.lower(), []) - - if language.lower() in ["python", "bash", "php"]: - # Comments (#) - patterns.append((r"#.*$", "comment")) - - if language.lower() in ["javascript", "php", "css"]: - # Comments (//) - patterns.append((r"//.*$", "comment")) - # Multi-line comments (/* */) - patterns.append((r"/\*.*?\*/", "comment")) - - if language.lower() in ["html", "xml"]: - # HTML/XML tags - patterns.append((r"<[^>]+>", "tag")) - # HTML comments - patterns.append((r"", "comment")) - - # Numbers - patterns.append((r"\b\d+\.?\d*\b", "number")) - - # Keywords - for keyword in keywords: - patterns.append((r"\b" + keyword + r"\b", "keyword")) - - # Do strings last so they override any of the above (e.g reserved keywords in strings) - - # Strings (double quotes) - patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string")) - - # Strings (single quotes) - patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string")) - - return patterns - - @staticmethod - def get_format_for_type( - format_type: str, base_format: QTextCharFormat - ) -> QTextCharFormat: - """Get text format for a specific syntax type.""" - fmt = QTextCharFormat(base_format) - - if format_type == "keyword": - fmt.setForeground(QColor(86, 156, 214)) # Blue - fmt.setFontWeight(QFont.Weight.Bold) - elif format_type == "string": - fmt.setForeground(QColor(206, 145, 120)) # Orange - elif format_type == "comment": - fmt.setForeground(QColor(106, 153, 85)) # Green - fmt.setFontItalic(True) - elif format_type == "number": - fmt.setForeground(QColor(181, 206, 168)) # Light green - elif format_type == "tag": - fmt.setForeground(QColor(78, 201, 176)) # Cyan - - return fmt - - -class CodeBlockMetadata: - """Stores metadata about code blocks (language, etc.) for a document.""" - - def __init__(self): - self._block_languages: Dict[int, str] = {} # block_number -> language - - def set_language(self, block_number: int, language: str): - """Set the language for a code block.""" - self._block_languages[block_number] = language.lower() - - def get_language(self, block_number: int) -> Optional[str]: - """Get the language for a code block.""" - return self._block_languages.get(block_number) - - def serialize(self) -> str: - """Serialize metadata to a string.""" - # Store as JSON-like format in a comment at the end - if not self._block_languages: - return "" - - items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())] - return "" - - 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 ba6b6ce..4e1fbf8 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1,58 +1,16 @@ from __future__ import annotations import csv -import datetime as _dt -import hashlib import html import json -import markdown -import re +import os from dataclasses import dataclass from pathlib import Path from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple, Dict - - -from . import strings +from typing import List, Sequence, Tuple 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 @@ -62,11 +20,6 @@ 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: @@ -110,12 +63,8 @@ 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( - strings._("db_sqlcipher_integrity_check_failed") - + ( - f": {details}" - if details - else f" ({len(rows)} {strings._('db_issues_reported')})" - ) + "SQLCipher integrity check failed" + + (f": {details}" if details else f" ({len(rows)} issue(s) reported)") ) def _ensure_schema(self) -> None: @@ -127,6 +76,7 @@ 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 ( @@ -147,72 +97,33 @@ 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: @@ -220,6 +131,8 @@ 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() @@ -230,7 +143,7 @@ class DBManager: self.conn = None self.cfg.key = new_key if not self.connect(): - raise sqlite.Error(strings._("db_reopen_failed_after_rekey")) + raise sqlite.Error("Re-open failed after rekey") def get_entry(self, date_iso: str) -> str: """ @@ -250,31 +163,22 @@ class DBManager: def search_entries(self, text: str) -> list[str]: """ - Search for entries by term or tag name. - This only works against the latest version of the page. + Search for entries by term. This only works against the latest + version of the page. """ cur = self.conn.cursor() - q = text.strip() - pattern = f"%{q.lower()}%" - + pattern = f"%{text}%" rows = cur.execute( """ - SELECT DISTINCT p.date, v.content + SELECT 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 ( - LOWER(v.content) LIKE ? - OR LOWER(COALESCE(t.name, '')) LIKE ? - ) + AND v.content LIKE LOWER(?) ESCAPE '\\' ORDER BY p.date DESC; """, - (pattern, pattern), + (pattern,), ).fetchall() return [(r[0], r[1]) for r in rows] @@ -288,8 +192,7 @@ 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; """ @@ -308,6 +211,8 @@ 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 @@ -375,9 +280,7 @@ class DBManager: "SELECT date FROM versions WHERE id=?;", (version_id,) ).fetchone() if row is None or row["date"] != date_iso: - raise ValueError( - strings._("db_version_id_does_not_belong_to_the_given_date") - ) + raise ValueError("version_id does not belong to the given date") with self.conn: cur.execute( @@ -385,17 +288,6 @@ 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]: """ @@ -430,6 +322,52 @@ 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"
" - f"
" - f"
{body_html}
" - f"
" + f"
{c}
" ) parts.append("") @@ -478,8 +397,8 @@ class DBManager: self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" ) -> None: """ - Export the data to a markdown file. Since the data is already Markdown, - nothing more to do. + Export to HTML, similar to export_html, but then convert to Markdown + using markdownify, and finally save to file. """ parts = [] for d, c in entries: @@ -508,6 +427,29 @@ 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. @@ -516,624 +458,9 @@ class DBManager: cur = self.conn.cursor() cur.execute("VACUUM") except Exception as 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 - ] + print(f"Error: {e}") 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/find_bar.py b/bouquin/find_bar.py index ae0206b..47490d6 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -17,14 +17,13 @@ from PySide6.QtWidgets import ( QTextEdit, ) -from . import strings - class FindBar(QWidget): """Widget for finding text in the Editor""" - # emitted when the bar is hidden (Esc/✕), so caller can refocus editor - closed = Signal() + closed = ( + Signal() + ) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor def __init__( self, @@ -32,28 +31,23 @@ class FindBar(QWidget): shortcut_parent: QWidget | None = None, parent: QWidget | None = None, ): - super().__init__(parent) + self.editor = editor - # store how to get the current editor - self._editor_getter = editor if callable(editor) else (lambda: editor) - self.shortcut_parent = shortcut_parent - - # UI (build ONCE) + # UI layout = QHBoxLayout(self) layout.setContentsMargins(6, 0, 6, 0) - layout.addWidget(QLabel(strings._("find"))) - + layout.addWidget(QLabel("Find:")) self.edit = QLineEdit(self) - self.edit.setPlaceholderText(strings._("find_bar_type_to_search")) + self.edit.setPlaceholderText("Type to search…") layout.addWidget(self.edit) - self.case = QCheckBox(strings._("find_bar_match_case"), self) + self.case = QCheckBox("Match case", self) layout.addWidget(self.case) - self.prevBtn = QPushButton(strings._("previous"), self) - self.nextBtn = QPushButton(strings._("next"), self) + self.prevBtn = QPushButton("Prev", self) + self.nextBtn = QPushButton("Next", self) self.closeBtn = QPushButton("✕", self) self.closeBtn.setFlat(True) layout.addWidget(self.prevBtn) @@ -62,15 +56,11 @@ class FindBar(QWidget): self.setVisible(False) - # Shortcut (press Esc to hide bar) - sp = ( - self.shortcut_parent - if self.shortcut_parent is not None - else (self.parent() or self) - ) - QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) + # Shortcut escape key to close findBar + sp = shortcut_parent if shortcut_parent is not None else (parent or self) + self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) - # Signals (connect ONCE) + # Signals self.edit.returnPressed.connect(self.find_next) self.edit.textChanged.connect(self._update_highlight) self.case.toggled.connect(self._update_highlight) @@ -78,17 +68,10 @@ class FindBar(QWidget): self.prevBtn.clicked.connect(self.find_prev) self.closeBtn.clicked.connect(self.hide_bar) - @property - def editor(self) -> QTextEdit | None: - """Get the current editor""" - return self._editor_getter() - # ----- Public API ----- def show_bar(self): """Show the bar, seed with current selection if sensible, focus the line edit.""" - if not self.editor: - return tc = self.editor.textCursor() sel = tc.selectedText().strip() if sel and "\u2029" not in sel: # ignore multi-paragraph selections @@ -172,8 +155,6 @@ class FindBar(QWidget): self._update_highlight() def _update_highlight(self): - if not self.editor: - return txt = self.edit.text() if not txt: self._clear_highlight() @@ -202,5 +183,4 @@ class FindBar(QWidget): self.editor.setExtraSelections(selections) def _clear_highlight(self): - if self.editor: - self.editor.setExtraSelections([]) + self.editor.setExtraSelections([]) diff --git a/bouquin/flow_layout.py b/bouquin/flow_layout.py deleted file mode 100644 index e2a1c5a..0000000 --- a/bouquin/flow_layout.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QPoint, QRect, QSize, Qt -from PySide6.QtWidgets import QLayout - - -class FlowLayout(QLayout): - def __init__( - self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4 - ): - super().__init__(parent) - self._items = [] - self._hspace = hspacing - self._vspace = vspacing - self.setContentsMargins(margin, margin, margin, margin) - - def addItem(self, item): - self._items.append(item) - - def itemAt(self, index): - if 0 <= index < len(self._items): - return self._items[index] - return None - - def takeAt(self, index): - if 0 <= index < len(self._items): - return self._items.pop(index) - return None - - def count(self): - return len(self._items) - - def expandingDirections(self): - return Qt.Orientations(Qt.Orientation(0)) - - def hasHeightForWidth(self): - return True - - def heightForWidth(self, width: int) -> int: - return self._do_layout(QRect(0, 0, width, 0), test_only=True) - - def setGeometry(self, rect: QRect): - super().setGeometry(rect) - self._do_layout(rect, test_only=False) - - def sizeHint(self) -> QSize: - return self.minimumSize() - - def minimumSize(self) -> QSize: - size = QSize() - for item in self._items: - size = size.expandedTo(item.minimumSize()) - left, top, right, bottom = self.getContentsMargins() - size += QSize(left + right, top + bottom) - return size - - def _do_layout(self, rect: QRect, test_only: bool) -> int: - x = rect.x() - y = rect.y() - line_height = 0 - - left, top, right, bottom = self.getContentsMargins() - effective_rect = rect.adjusted(+left, +top, -right, -bottom) - x = effective_rect.x() - y = effective_rect.y() - max_right = effective_rect.right() - - for item in self._items: - wid = item.widget() - if wid is None or not wid.isVisible(): - continue - space_x = self._hspace - space_y = self._vspace - next_x = x + item.sizeHint().width() + space_x - if next_x - space_x > max_right and line_height > 0: - # Wrap - x = effective_rect.x() - y = y + line_height + space_y - next_x = x + item.sizeHint().width() + space_x - line_height = 0 - - if not test_only: - item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) - - x = next_x - line_height = max(line_height, item.sizeHint().height()) - - return y + line_height - rect.y() + bottom diff --git a/bouquin/fonts/NotoSans-Regular.ttf b/bouquin/fonts/NotoSans-Regular.ttf deleted file mode 100644 index 4bac02f..0000000 Binary files a/bouquin/fonts/NotoSans-Regular.ttf and /dev/null differ diff --git a/bouquin/fonts/NotoSansSymbols2-Regular.ttf b/bouquin/fonts/NotoSansSymbols2-Regular.ttf deleted file mode 100644 index 7816268..0000000 Binary files a/bouquin/fonts/NotoSansSymbols2-Regular.ttf and /dev/null differ diff --git a/bouquin/fonts/OFL.txt b/bouquin/fonts/OFL.txt deleted file mode 100644 index 106e5d8..0000000 --- a/bouquin/fonts/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index f2cdc1c..1a4c029 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -13,11 +13,8 @@ from PySide6.QtWidgets import ( QMessageBox, QTextBrowser, QTabWidget, - QAbstractItemView, ) -from . import strings - def _markdown_to_text(s: str) -> str: """Convert markdown to plain text for diff comparison.""" @@ -46,9 +43,7 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str: """Return HTML with colored unified diff (+ green, - red, context gray).""" a = _markdown_to_text(old_md).splitlines() b = _markdown_to_text(new_md).splitlines() - ud = difflib.unified_diff( - a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm="" - ) + ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="") lines = [] for line in ud: if line.startswith("+") and not line.startswith("+++"): @@ -72,7 +67,7 @@ class HistoryDialog(QDialog): def __init__(self, db, date_iso: str, parent=None): super().__init__(parent) - self.setWindowTitle(f"{strings._('history')} — {date_iso}") + self.setWindowTitle(f"History — {date_iso}") self._db = db self._date = date_iso self._versions = [] # list[dict] from DB @@ -83,7 +78,6 @@ class HistoryDialog(QDialog): # Top: list of versions top = QHBoxLayout() self.list = QListWidget() - self.list.setSelectionMode(QAbstractItemView.ExtendedSelection) self.list.setMinimumSize(500, 650) self.list.currentItemChanged.connect(self._on_select) top.addWidget(self.list, 1) @@ -94,8 +88,8 @@ class HistoryDialog(QDialog): self.preview.setOpenExternalLinks(True) self.diff = QTextBrowser() self.diff.setOpenExternalLinks(False) - self.tabs.addTab(self.preview, strings._("history_dialog_preview")) - self.tabs.addTab(self.diff, strings._("history_dialog_diff")) + self.tabs.addTab(self.preview, "Preview") + self.tabs.addTab(self.diff, "Diff") self.tabs.setMinimumSize(500, 650) top.addWidget(self.tabs, 2) @@ -104,38 +98,39 @@ class HistoryDialog(QDialog): # Buttons row = QHBoxLayout() row.addStretch(1) - self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected")) + self.btn_revert = QPushButton("Revert to Selected") self.btn_revert.clicked.connect(self._revert) - self.btn_delete = QPushButton(strings._("history_dialog_delete")) - self.btn_delete.clicked.connect(self._delete) - self.btn_close = QPushButton(strings._("close")) + self.btn_close = QPushButton("Close") self.btn_close.clicked.connect(self.reject) row.addWidget(self.btn_revert) - row.addWidget(self.btn_delete) row.addWidget(self.btn_close) root.addLayout(row) self._load_versions() # --- Data/UX helpers --- - def _load_versions(self): - # [{id,version_no,created_at,note,is_current}] - self._versions = self._db.list_versions(self._date) + def _fmt_local(self, iso_utc: str) -> str: + """ + Convert UTC in the database to user's local tz + """ + dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00")) + local = dt.astimezone() + return local.strftime("%Y-%m-%d %H:%M:%S %Z") + def _load_versions(self): + self._versions = self._db.list_versions( + self._date + ) # [{id,version_no,created_at,note,is_current}] self._current_id = next( (v["id"] for v in self._versions if v["is_current"]), None ) self.list.clear() for v in self._versions: - created_at = datetime.fromisoformat( - v["created_at"].replace("Z", "+00:00") - ).astimezone() - created_at_local = created_at.strftime("%Y-%m-%d %H:%M:%S %Z") - label = f"v{v['version_no']} — {created_at_local}" + label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}" if v.get("note"): label += f" · {v['note']}" if v["is_current"]: - label += " **(" + strings._("current") + ")**" + label += " **(current)**" it = QListWidgetItem(label) it.setData(Qt.UserRole, v["id"]) self.list.addItem(it) @@ -150,28 +145,31 @@ class HistoryDialog(QDialog): @Slot() def _on_select(self): - selected_items = self.list.selectedItems() item = self.list.currentItem() - if not item or len(selected_items) > 1: + if not item: self.preview.clear() self.diff.clear() self.btn_revert.setEnabled(False) return - sel_id = item.data(Qt.UserRole) + # Preview selected as plain text (markdown) sel = self._db.get_version(version_id=sel_id) - self.preview.setMarkdown(sel["content"]) + # Show markdown as plain text with monospace font for better readability + self.preview.setPlainText(sel["content"]) + self.preview.setStyleSheet( + "font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;" + ) # Diff vs current (textual diff) cur = self._db.get_version(version_id=self._current_id) self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"])) - - # Enable revert and delete buttons only if selecting a non-current version + # Enable revert only if selecting a non-current version self.btn_revert.setEnabled(sel_id != self._current_id) - self.btn_delete.setEnabled(sel_id != self._current_id) @Slot() def _revert(self): item = self.list.currentItem() + if not item: + return sel_id = item.data(Qt.UserRole) if sel_id == self._current_id: return @@ -179,24 +177,6 @@ class HistoryDialog(QDialog): try: self._db.revert_to_version(self._date, version_id=sel_id) except Exception as e: - QMessageBox.critical( - self, strings._("history_dialog_revert_failed"), str(e) - ) + QMessageBox.critical(self, "Revert failed", str(e)) return self.accept() - - @Slot() - def _delete(self): - selected_items = self.list.selectedItems() - for item in selected_items: - sel_id = item.data(Qt.UserRole) - if sel_id == self._current_id: - return - try: - self._db.delete_version(version_id=sel_id) - except Exception as e: - QMessageBox.critical( - self, strings._("history_dialog_delete_failed"), str(e) - ) - return - return self._load_versions() diff --git a/bouquin/icons/bouquin.svg b/bouquin/icons/bouquin.svg deleted file mode 100644 index b282050..0000000 --- a/bouquin/icons/bouquin.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 195599f..bef0571 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -1,107 +1,47 @@ from __future__ import annotations -from pathlib import Path - from PySide6.QtWidgets import ( QDialog, QVBoxLayout, - QHBoxLayout, QLabel, QLineEdit, QPushButton, QDialogButtonBox, - QFileDialog, ) -from . import strings - class KeyPrompt(QDialog): def __init__( self, parent=None, - title: str = strings._("key_prompt_enter_key"), - message: str = strings._("key_prompt_enter_key"), - initial_db_path: str | Path | None = None, - show_db_change: bool = False, + title: str = "Enter key", + message: str = "Enter key", ): """ Prompt the user for the key required to decrypt the database. Used when opening the app, unlocking the idle locked screen, or when rekeying. - - If show_db_change is true, also show a QFileDialog allowing to - select a database file, else the default from settings is used. """ super().__init__(parent) self.setWindowTitle(title) - - self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None - v = QVBoxLayout(self) - v.addWidget(QLabel(message)) - - # DB chooser - self.path_edit: QLineEdit | None = None - if show_db_change: - path_row = QHBoxLayout() - self.path_edit = QLineEdit() - if self._db_path is not None: - self.path_edit.setText(str(self._db_path)) - - browse_btn = QPushButton(strings._("select_notebook")) - - def _browse(): - start_dir = str(self._db_path or "") - fname, _ = QFileDialog.getOpenFileName( - self, - strings._("select_notebook"), - start_dir, - "SQLCipher DB (*.db);;All files (*)", - ) - if fname: - self._db_path = Path(fname) - if self.path_edit is not None: - self.path_edit.setText(fname) - - browse_btn.clicked.connect(_browse) - - path_row.addWidget(self.path_edit, 1) - path_row.addWidget(browse_btn) - v.addLayout(path_row) - - # Key entry - self.key_entry = QLineEdit() - self.key_entry.setEchoMode(QLineEdit.Password) - v.addWidget(self.key_entry) - - toggle = QPushButton(strings._("show")) + self.edit = QLineEdit() + self.edit.setEchoMode(QLineEdit.Password) + v.addWidget(self.edit) + toggle = QPushButton("Show") toggle.setCheckable(True) toggle.toggled.connect( - lambda c: self.key_entry.setEchoMode( + lambda c: self.edit.setEchoMode( QLineEdit.Normal if c else QLineEdit.Password ) ) v.addWidget(toggle) - bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) v.addWidget(bb) - self.key_entry.setFocus() - self.resize(500, self.sizeHint().height()) - def key(self) -> str: - return self.key_entry.text() - - def db_path(self) -> Path | None: - """Return the chosen DB path (or None if unchanged/not shown).""" - p = self._db_path - if self.path_edit is not None: - text = self.path_edit.text().strip() - if text: - p = Path(text) - return p + return self.edit.text() diff --git a/bouquin/keys/mig5.asc b/bouquin/keys/mig5.asc deleted file mode 100644 index 81d5fc7..0000000 --- a/bouquin/keys/mig5.asc +++ /dev/null @@ -1,109 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQINBGQiioEBEAD2hJIaDsfkURHpA9KUXQQezeNhSiUcIheT3vP7Tb8nU2zkIgdy -gvwvuUcXKjUn22q+paqbQu+skYEjtLEFo59ZlS2VOQ6f9ukTGu2O6HWqFWncH3Vv -Pf0UeitNOoWi+qA14mtC7c/SxuHtMG4hmlHILGZg9mlSZfpt7oyczFtV7YG9toRe -gvyM8h2BRSi3EXigsymVMgpYcW3bESVxOnNJdNEFP8fKzR9Bu7rc99abRPm5p6gw -cYo9FAdLoiE8QcNU79hQ5UTAULWXFo3hduQfAs3y0f+g8FGJZUF40Gb8YJDtarRA -J7B9/XdfDNDZE00/QxV2gUGbLVTbVjqn6dKhEOTfuvSmfQxqNNy2a1ewpJrNnsvh -XGvSzZVLNy/c4CEROisRqDCa8xUb/snnHy7gGEuD5DXqQL3wnbTXu92N8gVxLegS -fr9NW2I6/eXWrlXhWJdP5ZH9yq7FVkWha2gTByP6bcxDBvQCzKyYg4JbY9bQDtJf -z7W2W9V6QHMiGJ9/ApfgTjKn0peiouGS8GGCPqLLyVGblEIJmSfEU+0BPq9PurRH -RR/T7E4wVi3bgOfj9G5Z8dMBWh5BzN7PqxQvO1lCx7ZZteNkt/wXglLHB0eghnD0 -BCxuZ7lN12NW+lTf9s/kc0PS8YgZ0/AIFv45PHX1sVcxXizT49HQUbHa1wARAQAB -tBpNaWd1ZWwgSmFjcSA8bWlnQG1pZzUubmV0PokCVAQTAQoAPhYhBACugXwkoQwl -QEYanB183gI020WNBQJkIoqBAhsDBQkFo5qABQsJCAcDBRUKCQgLBRYCAwEAAh4B -AheAAAoJEB183gI020WN+2AQALJ58Qr4P3/lON50ulG/RgIYxXlPnyy4Ai1bDJiI -t3pLOWGQkGza6lw07rEh8Bs6w9sQ7WrpfzLRaYgqhfkBNbMtim8hRNZUuE/8O+v3 -k9GRVYCe9RWazKhno+RljJy4TaqiqBeGxnryDJWxk8O4dXmQAnsFPF09xNpktgOC -mGbclA+rM8dY3bgq5wJ5Bh10zW4psfoAT1wFYX/oV19vlHbhRx3bavoWDS4lmXYv -oWy9xwacDVoZYcbGPif3xbMbttdKH7ijf+asM3wYUsIrHeOPdHl+YK45e6AGdjwL -mvp0P4YQo8Yk3yfH3L/km/no8rwcrPbk7+lX06x2GEjOiM2OIKAZYMZnL0BREgt4 -XsD2hcQpuowxHmI2X2CHk8TnPhAXyNdX7Ss/geQ6Zx/q1Ts+mhhfQVa9AIRS+HDm -LURQRdZKBD1mB2hJsuF2WCyczuJ8jhBc+wSX/WXnQHLi2cG3OAC1udxrdDIckWb8 -4CojEbk05cnMLR3dPV/g1JeXunib569RNTAijaTr39VRBZepYJX/sO46iag2+0A4 -q41FgId2BwUS3GoyaIFZc5+MwLn65uYMgbIkfVlNkWEujoWV/aVLMrRa0udq4ZRE -ymPU8pfMhEWb3uvYCv+ed7sVxsVUMWeuQpyBQuPP1qlIzmsrEkRKryYH+ij4Vzri -OWvbuQINBGQiizkBEAC07TI3uqEUWKivXf5Mg1HnMAhpmv6yimr8PDfh3Dczy0eP -oCB6iq5wKCjYsp12E3kv3dcW4Ox8T+5U/B5ZP2lro63yeLSORUSz+jMq27rgtGmV -QFZNdKkzBzfPyzjKiZz4KaYE7Pn6v15In65SRqwqAXYUTkEoii+Ykk32qzZWIVCR -ixpRQGbBi+/XipONp8KCQANOSWSzTf8s7U1y4yhW1yCeUOK67LsSRlCtBpDWD7ki -MfX/nzSQyaXHDOrhkfVshU8eiln2Qf3mYg8gJmfFOb0zILhvCf3Sk312GtdxJo1m -B95TrDY8/7+1+l0wVrTq69tJXjQjBSmk1PBvNthSXCvuADnF8NxQlQuZtyI+rC4T -VInuLTr58YrmRIbGzOrFz+z6c532SB9F2PZvezjJ8LPDGCwW8dM6ADQxIw5cV0YE -hb5liFpeIX/NOnd1kus8Q6jyS0vzFqfgZC9kBFUTaXBM+mpDg1GYB4WS7baBQn3P -Z+7wvcN7VkfSBT2B79gJK0vfutJWBuK3p2435/KkD4PcAm6uBYL52b+Za06PQfgu -GaKxXRLREq/KCbYm4IKBkD8HRH9dmdd2U8YsApNWQ/oAHCfWvimhYUD9YOJimDwp -hX7FkaF/xHdi1/8hG8h2lok4cCtbaZPGXAUKuKHDhDFAI/OiIgv4nxq+A5kzfwAR -AQABiQRyBBgBCgAmFiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQiizkCGwIFCQWj -moACQAkQHXzeAjTbRY3BdCAEGQEKAB0WIQQ4BFZaXvpsEa/aDlNZs/DCQTXGqQUC -ZCKLOQAKCRBZs/DCQTXGqTv6D/9eFMA3ReSg1sfPsyEFj9JiJ3H3aOJX5R5/2xdI -QZLTjH0iapgGm3h8v+bFdr4+y3xWHpcaxBJsccyOZxzr0xjr+qt5t6OZrE+e1pQh -Hw/Kt7m5SiCmbGM6I3aECv8zU4EpGUf/FXLcaBaot4eR4uPRjBLatngzLw+5Mjk1 -ZBjmyA5OaAqQzrDXPhFBItsSlHJeBOrpbzqxdjQi2AHD+L50itgfsoDOfVtmELZN -heW7xn83U2iqgu3bEq4Ug8lqh2KVBHELoxErQR+wTAIxgj/CwhVDQdrKhQ4ypbLh -O/oPlMmGFcBoMhCATNWitdqQUu7EHAECGyWCns8hm1OksqHMnbNhOzmRkl18UroZ -a1CJPFpeaEC25U37+yPEUiG4dJE8iiZAfyjv0AN1TbXzov5g9g/Xz+BmVALtOYBJ -fWKH/aTg5CU2GY9ts+bYDz+mli39h7FQQfcW+zjVWft2P4R7FvG0DBEJkbyw053R -++CEO1ARsMyygy2ukwkA06nYPlbaH5wEpQl2NV5PeYt66eU4epgL7y89/DhOSBig -JJJk+OASEh3o7rC/EkrlF/GQD8ZwO1oBO11ueDft7QU6P/TAzNqyywqZiy76kzdw -1qU77vhXlGtZQCuxbfgvpLin1ivhOaR/6gfDmsfUlSne5kp+uUrgoRhhEc/krOci -fGSFcutPD/4pziVea31UcngwJRo/s9AfHkjviVMpGJIQo3vtejq53UQu8yWWc/uW -G5z+pxOuK3QdTjtzrmOiCGj1bWZ+I33K+fBbZcf7C+o4HV9KaexW1db3wBtwUFWO -7TFezkBDaKbgxgaryh1+RcetQP7cdN2Chcy0EWf10S8/N8whj2ZyAcIuIoT8wM7i -xWmnQRiI2l2+7AhQfqGFUk+PEYRvRyRtjF8X9buYVBh/9rFrScH6aK+gicCcU1gJ -Zpc51QEDDSfAYF6wV8pWnILKcXqdDZhEh1hnTUitUL9mlZEaenGjSPCtcGVg3s9l -CuXJij89s74IyfCdjJsmy9K5GxQyhUJb0nyy5wOpGPGmDueTiP32JuXOxNeEp+gY -3rxygMNzAmL2QjLajLpE6kj+mEMBYSTWyni1W7c5i0PnOsi22yXV+2W+XaeC+9Pm -424uM8e2Y0+C9lI6AqDziL58fP2V6FxJTpbzBxANqKwSh5N0we1Cfw/ZPC0LyebZ -KbmPcNoSoqaOYXo3h0LFsDL2aA0PTJroAV1p/xxVoxDeGkX+hJXh+6ErVhEOb+gv -+LiUabBFtHTa7yPVtQWLFWf4njFQIytt8iDTpFDfK1OApe25xilrTRZT147KtKwL -5tDl33hFKbspcqALa7ozwE1Tr8/yrddainGQSIfx4CAfk8P5aqi19LkCDQRkIotT -ARAAxjaJMoCvKYNWaJ5m9K9KsfoKss8CXiy3SEhbcqh/Yy4osiODjoWjS+lsz58G -uyPphLXjdhIn9DWPnYKKoV7sB1y2RTCLsZ9jJaqHBL3e+gL78zS8hNHcq3HxWEwb -SYRHr8pBKWL7/X4m+2cuMC/wnK+QWIGB4S03yMZGMbC8GTfuj6tdO4GZYfCGVWHi -gv1ERGaArlqmXk+TkQQmTUpfhdqNBKWllZK56/oUMDNGsRrgEP8TzU4z+YbJK0FJ -7V9dY1j28K8oqLDgA+/aiLv2gpS+qsmowMhxKN/axvF+FCZbGS3+/h4subZMIcbI -xxDHSPqPgA+f0GQHIHsy9gELMQtkXTP5xzZuoDGX+F2LFb68wHd3jCNpfFVEfTP2 -8CcyLbjciyY8wod6WLa7q0VNDlSGEXH5thaNnidCwynNCF+NaFQMVf027jThp6S/ -nWtUZFPCMGx9jj8mbopkSsfF7E9fErRtCI8dAnmcE/ottvueAN7Q3XAUlsilLM8M -HhkSZobaUBynewcEIpHSY4vOfRWnhQI60WGfD7x7dMuIakao9euSg9g/u7WMCV6U -ShElJdYdpZA/H/jMFb17zuH9yp5cGNNMeUP2WvEWtUHA36nGI4+oE3SszOSRF4+E -YAozF6Hh1MrC/hXe3NShoDq68hG5e1SsndLZ1B9Gt/nAqiEAEQEAAYkCPAQYAQoA -JhYhBACugXwkoQwlQEYanB183gI020WNBQJkIotTAhsMBQkFo5qAAAoJEB183gI0 -20WNldAP/17KozqrwUA8mlYU3zpc/P0HdBtL/rn5Fx87MZ2E8RPuVMyNg6I4KoU5 -Kmh0vy6cL8vG7fqYXM1ieiy9wTMxiGaWDL7QZY3LBXQ2mFfGd2rAAhwloTEcPn6i -Ro/X0C5aBGGy5iACOfpRA774XsNQG6cgBY/Jq0/D2Jom78Vv0k3H0oD1L5BrRO/H -5L9TriBW9el4F/USpaQDjR/KiSfsBr6HLpht1OQJ+21kUbGgvse7DdTtZeK4q3wR -1v4OV9EX1m09WUL+7Cra1OFSc9bZ0fcVY98zGXm8LTtipiBc//ZrDjMutRdOj4ct -RHDiKHBEYFxHGeAj87Xwc9q6ph2MspjXS4qHVJRWtyx5DQcrf6gY3bH73SByhOXj -SVDpfeDvO4BpQ+8q4d9AjcGa6NqGTXR8P5Y8jnZG68buwGstBbz2J2fHBs0SrBMg -3T6HSB3z4gD/WkPE8bT/9oMpSLD0mdHQAYJviOa39rRGII6Jzkd1EL9tVDU9QenX -hVx2v3ZWL8Iq1Bm8zwiDAGsiHcHmxY8sQmfuwWQdYXhxXBcG0kBNKz+158uyFr9u -Skp8e1INBDShReAQuQ5PAGBIrZ5aElPaK/2puNeAmd3cholvpeu0CuEaxpLi0Tq3 -y/xhPPFMdZ4llt90sotKeYnHmvsYUJe2on8afl9bwotz8On484vVuQINBGQii2cB -EAC/YnmAiKO05oN129GedPTDrvJk6PbXHUYb5UtNisAwLVXeKSpo5OWyckDZ1IoV -9xvOdH+TWJvgX5x7gPZoD9COYHfMQRZeysZ89wCocH55PsAwmvjM87rAKLbkyZl8 -sehgsri09amBlMoSeTVN49U5lt9EZWVKZeACtDk9D86OX7r154NM7uSxvQVeydth -Bj/Rdh15RUfsKTZYxmzZ/1x3FnHzOLTDkX5QmBIBlthVN2IaT8U8pfKpoStOlBza -j1MdrdhtkDH4YAFi2X9KlkoP3Z2fYCefVcLJw+k3D8nwPyXmGuJhG0oHsPyesQGz -FSnIM6ZWhqh76yS1EQxK125NKu9FeHJBAEOg0RISpe/LhNNLjUQ0dC9gRx9l+p46 -hIMUXwMPNENMFihNqP4tRLvF/0KI1oj7634rei+dZKWuja6yk/QaOcztmcyS2Aca -n3llExISb3beNncQHaAYg8ADHR+852RZQ81yUFUF7yrxclSJmF5zO4fJAedacClA -FuGnQvIQZv01YULOtDn3fTq8eY912VZx+SxpO2IwTObYCdnSBHigQBp13UTcg5WV -HhmfwJKI328GaPkBa0eIqxc5gR7X6PmrLvxlCbrMC9IHjlwd203eKMhqRoIJYXEv -Ebsx02Zceh4tMH9RDH2XNpHLt604rCLJTReRORXsAH/zBQARAQABiQI8BBgBCgAm -FiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQii2cCGyAFCQWjmoAACgkQHXzeAjTb -RY1TiA/+N4dIfoHMsEZ53DwrbRqDXlzTfkfqWd99tE72Lecsns2ih4/4wHOgzV7z -SV6002SZK/PHRYikmxSSxmoNbx5yNMp9vI8j031YShAJd6QU+NVjY3oB4ivF6wRa -vP2OYO0vamwTw54e5quKmg+ZntFhWY55YNWCqqcYZdHI4GtvbhsCEuS/ceZ1XoXY -xbtaNJHAn5yG+/VLNu2fiAiu+e4+xEQ2UjV8rC60MU9tZafMbALlHUXGDY0tUCzv -/BF3GDQk3dxN+fEBnassVXgZm30dOB2XqVIF5g+l6iufmT9WcDTbnXyYbEBRVTJ1 -DpTbmtwUpuYdSX41NPPojK3XcesP+PR8x7tWU7AEWzV827I4sx54HjJVMj2TWSGB -X+xDgthbqqtm1VZPNL2yHJzxHgIPqo6iQLaAGphR/L+ULFeJnFNjgOatt7vcG7pr -ZVLK1Kq+gc0X+73grlm89XC5R3mNFNOUMWXJ7YniqzCzsTiOwyGP40pvY1vP8v61 -509UcUjfXyIhls6vAl1jo/BA0jLuUODQ9P4QqWm4wy7MzMfWBmWKsaubCiiHuala -rXFaJVtIgM/bl089klXVzxD3Beo0PCnuU/6qBgkM6ulS+/wxqU7chW6ClHwdY8U0 -NU3X/uocFtQrI3WLcE0vMc0IHa8VjDb8r6ztC9Vsti6iPMdScOM= -=IfFs ------END PGP PUBLIC KEY BLOCK----- diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json deleted file mode 100644 index eb5dd83..0000000 --- a/bouquin/locales/en.json +++ /dev/null @@ -1,293 +0,0 @@ -{ - "db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed", - "db_issues_reported": "issue(s) reported", - "db_reopen_failed_after_rekey": "Re-open failed after rekey", - "db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date", - "db_key_incorrect": "The key is probably incorrect", - "db_database_error": "Database error", - "database_maintenance": "Database maintenance", - "database_compact": "Compact the database", - "database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.", - "database_compacted_successfully": "Database compacted successfully!", - "encryption": "Encryption", - "remember_key": "Remember key", - "change_encryption_key": "Change encryption key", - "enter_a_new_encryption_key": "Enter a new encryption key", - "reenter_the_new_key": "Re-enter the new key", - "key_mismatch": "Key mismatch", - "key_mismatch_explanation": "The two entries did not match.", - "empty_key": "Empty key", - "empty_key_explanation": "The key cannot be empty.", - "key_changed": "Key changed", - "key_changed_explanation": "The notebook was re-encrypted with the new key!", - "error": "Error", - "success": "Success", - "close": "&Close", - "find": "Find", - "file": "File", - "locale": "Language", - "locale_restart": "Please restart the application to load the new language.", - "settings": "Settings", - "theme": "Theme", - "system": "System", - "light": "Light", - "dark": "Dark", - "never": "Never", - "close_tab": "Close tab", - "previous": "Previous", - "previous_day": "Previous day", - "next": "Next", - "next_day": "Next day", - "today": "Today", - "show": "Show", - "history": "History", - "export_accessible_flag": "&Export", - "export_entries": "Export entries", - "export_complete": "Export complete", - "export_failed": "Export failed", - "backup": "Backup", - "backup_complete": "Backup complete", - "backup_failed": "Backup failed", - "quit": "Quit", - "cancel": "Cancel", - "save": "Save", - "help": "Help", - "saved": "Saved", - "saved_to": "Saved to", - "documentation": "Documentation", - "couldnt_open": "Couldn't open", - "report_a_bug": "Report a bug", - "version": "Version", - "update": "Update", - "check_for_updates": "Check for updates", - "could_not_check_for_updates": "Could not check for updates:\n", - "update_server_returned_an_empty_version_string": "Update server returned an empty version string", - "you_are_running_the_latest_version": "You are running the latest version:\n", - "there_is_a_new_version_available": "There is a new version available:\n", - "download_the_appimage": "Download the AppImage?", - "downloading": "Downloading", - "download_cancelled": "Download cancelled", - "failed_to_download_update": "Failed to download update:\n", - "could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n", - "could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.", - "gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n", - "downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n", - "navigate": "Navigate", - "current": "current", - "selected": "selected", - "find_on_page": "Find on page", - "find_next": "Find next", - "find_previous": "Find previous", - "find_bar_type_to_search": "Type to search", - "find_bar_match_case": "Match case", - "history_dialog_preview": "Preview", - "history_dialog_diff": "Diff", - "history_dialog_revert_to_selected": "&Revert to selected", - "history_dialog_revert_failed": "Revert failed", - "history_dialog_delete": "&Delete revision", - "history_dialog_delete_failed": "Could not delete revision", - "key_prompt_enter_key": "Enter key", - "lock_overlay_locked": "Locked", - "lock_overlay_unlock": "Unlock", - "main_window_lock_screen_accessibility": "&Lock screen", - "main_window_ready": "Ready", - "main_window_save_a_version": "Save a version", - "main_window_settings_accessible_flag": "Settin&gs", - "set_an_encryption_key": "Set an encryption key", - "set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!", - "unlock_encrypted_notebook": "Unlock encrypted notebook", - "unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook", - "open_in_new_tab": "Open in new tab", - "autosave": "autosave", - "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", - "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", - "insert_images": "Insert images", - "images": "Images", - "reopen_failed": "Re-open failed", - "unlock_failed": "Unlock failed", - "could_not_unlock_database_at_new_path": "Could not unlock database at new path.", - "unencrypted_export": "Unencrypted export", - "unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.", - "unrecognised_extension": "Unrecognised extension!", - "backup_encrypted_notebook": "Backup encrypted notebook", - "enter_a_name_for_this_version": "Enter a name for this version", - "new_version_i_saved_at": "New version I saved at", - "appearance": "Appearance", - "security": "Security", - "features": "Features", - "database": "Database", - "save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.", - "lock_screen_when_idle": "Lock screen when idle", - "autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.", - "font_size": "Font size", - "font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size", - "search_for_notes_here": "Search for notes here", - "toolbar_format": "Format", - "toolbar_bold": "Bold", - "toolbar_italic": "Italic", - "toolbar_strikethrough": "Strikethrough", - "toolbar_normal_paragraph_text": "Normal paragraph text", - "toolbar_font_smaller": "Smaller text", - "toolbar_font_larger": "Larger text", - "toolbar_bulleted_list": "Bulleted list", - "toolbar_numbered_list": "Numbered list", - "toolbar_code_block": "Code block", - "toolbar_heading": "Heading", - "toolbar_toggle_checkboxes": "Toggle checkboxes", - "tags": "Tags", - "tag": "Tag", - "manage_tags": "Manage tags", - "add_tag_placeholder": "Add a tag and press Enter", - "tag_browser_title": "Tag Browser", - "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.", - "color_hex": "Colour", - "date": "Date", - "add_a_tag": "Add a tag", - "edit_tag_name": "Edit tag name", - "new_tag_name": "New tag name:", - "change_color": "Change colour", - "delete_tag": "Delete tag", - "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.", - "tag_already_exists_with_that_name": "A tag already exists with that name", - "statistics": "Statistics", - "main_window_statistics_accessible_flag": "Stat&istics", - "stats_pages_with_content": "Pages with content (current version)", - "stats_total_revisions": "Total revisions", - "stats_page_most_revisions": "Page with most revisions", - "stats_total_words": "Total words (current versions)", - "stats_unique_tags": "Unique tags", - "stats_page_most_tags": "Page with most tags", - "stats_activity_heatmap": "Activity heatmap", - "stats_heatmap_metric": "Colour by", - "stats_metric_words": "Words", - "stats_metric_revisions": "Revisions", - "stats_no_data": "No statistics available yet.", - "select_notebook": "Select notebook", - "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.", - "bug_report_placeholder": "Type your bug report here", - "bug_report_empty": "Please enter some details about the bug before sending.", - "bug_report_send_failed": "Could not send bug report.", - "bug_report_sent_ok": "Bug report sent. Thank you!", - "send": "Send", - "reminder": "Reminder", - "set_reminder": "Set reminder prompt", - "reminder_no_text_fallback": "You scheduled a reminder to alert you now!", - "invalid_time_title": "Invalid time", - "invalid_time_message": "Please enter a time in the format HH:MM", - "dismiss": "Dismiss", - "toolbar_alarm": "Set reminder alarm", - "activities": "Activities", - "activity": "Activity", - "note": "Note", - "activity_delete_error_message": "A problem occurred deleting the activity", - "activity_delete_error_title": "Problem deleting activity", - "activity_rename_error_message": "A problem occurred renaming the activity", - "activity_rename_error_title": "Problem renaming activity", - "activity_required_message": "An activity name is required", - "activity_required_title": "Activity name required", - "add_activity": "Add activity", - "add_project": "Add project", - "add_time_entry": "Add time entry", - "time_period": "Time period", - "by_day": "by day", - "by_month": "by month", - "by_week": "by week", - "date_range": "Date range", - "delete_activity": "Delete activity", - "delete_activity_confirm": "Are you sure you want to delete this activity?", - "delete_activity_title": "Delete activity - are you sure?", - "delete_project": "Delete project", - "delete_project_confirm": "Are you sure you want to delete this project?", - "delete_project_title": "Delete project - are you sure?", - "delete_time_entry": "Delete time entry", - "group_by": "Group by", - "hours": "Hours", - "invalid_activity_message": "The activity is invalid", - "invalid_activity_title": "Invalid activity", - "invalid_project_message": "The project is invalid", - "invalid_project_title": "Invalid project", - "manage_activities": "Manage activities", - "manage_projects": "Manage projects", - "manage_projects_activities": "Manage project activities", - "open_time_log": "Open time log", - "project": "Project", - "project_delete_error_message": "A problem occurred deleting the project", - "project_delete_error_title": "Problem deleting project", - "project_rename_error_message": "A problem occurred renaming the project", - "project_rename_error_title": "Problem renaming project", - "project_required_message": "A project is required", - "project_required_title": "Project required", - "projects": "Projects", - "rename_activity": "Rename activity", - "rename_project": "Rename project", - "run_report": "Run report", - "add_activity_title": "Add activity", - "add_activity_label": "Add an activity", - "rename_activity_label": "Rename activity", - "add_project_title": "Add project", - "add_project_label": "Add a project", - "rename_activity_title": "Rename this activity", - "rename_project_label": "Rename project", - "rename_project_title": "Rename this project", - "select_activity_message": "Select an activity", - "select_activity_title": "Select activity", - "select_project_message": "Select a project", - "select_project_title": "Select project", - "time_log": "Time log", - "time_log_collapsed_hint": "Time log", - "time_log_date_label": "Time log date: {date}", - "time_log_for": "Time log for {date}", - "time_log_no_date": "Time log", - "time_log_no_entries": "No time entries yet", - "time_log_report": "Time log report", - "time_log_report_title": "Time log for {project}", - "time_log_report_meta": "From {start} to {end}, grouped {granularity}", - "time_log_total_hours": "Total time spent", - "time_log_with_total": "Time log ({hours:.2f}h)", - "time_log_total_hours": "Total for day: {hours:.2f}h", - "update_time_entry": "Update time entry", - "time_report_total": "Total: {hours:.2f} hours", - "no_report_title": "No report", - "no_report_message": "Please run a report before exporting.", - "total": "Total", - "export_csv": "Export CSV", - "export_csv_error_title": "Export failed", - "export_csv_error_message": "Could not write CSV file:\n{error}", - "export_pdf": "Export PDF", - "export_pdf_error_title": "PDF export failed", - "export_pdf_error_message": "Could not write PDF file:\n{error}", - "enable_tags_feature": "Enable Tags", - "enable_time_log_feature": "Enable Time Logging", - "enable_reminders_feature": "Enable Reminders", - "pomodoro_time_log_default_text": "Focus session", - "toolbar_pomodoro_timer": "Time-logging timer", - "set_code_language": "Set code language", - "cut": "Cut", - "copy": "Copy", - "paste": "Paste", - "start": "Start", - "pause": "Pause", - "resume": "Resume", - "stop_and_log": "Stop and log", - "once": "once", - "daily": "daily", - "weekdays": "weekdays", - "weekly": "weekly", - "set_reminder": "Set reminder", - "edit_reminder": "Edit reminder", - "reminder": "Reminder", - "time": "Time", - "once_today": "Once (today)", - "every_day": "Every day", - "every_weekday": "Every weekday (Mon-Fri)", - "every_week": "Every week", - "repeat": "Repeat", - "monday": "Monday", - "tuesday": "Tuesday", - "wednesday": "Wednesday", - "thursday": "Thursday", - "friday": "Friday", - "saturday": "Saturday", - "sunday": "Sunday", - "day": "Day" -} diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json deleted file mode 100644 index 3ba5ba6..0000000 --- a/bouquin/locales/fr.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher", - "db_issues_reported": "problème(s) signalé(s)", - "db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé", - "db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée", - "db_key_incorrect": "La clé est probablement incorrecte", - "db_database_error": "Erreur de base de données", - "database_maintenance": "Maintenance de la base de données", - "database_compact": "Compacter la base de données", - "database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.", - "database_compacted_successfully": "Base de données compactée avec succès !", - "encryption": "Chiffrement", - "remember_key": "Se souvenir de la clé", - "change_encryption_key": "Changer la clé de chiffrement", - "enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement", - "reenter_the_new_key": "Saisir de nouveau la nouvelle clé", - "key_mismatch": "Les clés ne correspondent pas", - "key_mismatch_explanation": "Les deux saisies ne correspondent pas.", - "empty_key": "La clé est vide", - "empty_key_explanation": "La clé ne peut pas être vide.", - "key_changed": "La clé a été modifiée", - "key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !", - "error": "Erreur", - "success": "Succès", - "close": "Fermer", - "find": "Rechercher", - "file": "Fichier", - "locale": "Langue", - "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.", - "settings": "Paramètres", - "theme": "Thème", - "system": "Système", - "light": "Clair", - "dark": "Sombre", - "never": "Jamais", - "close_tab": "Fermer l'onglet", - "previous": "Précédent", - "previous_day": "Jour précédent", - "next": "Suivant", - "next_day": "Jour suivant", - "today": "Aujourd'hui", - "show": "Afficher", - "history": "Historique", - "export_accessible_flag": "E&xporter", - "export_entries": "Exporter les entrées", - "export_complete": "Exportation terminée", - "export_failed": "Échec de l'exportation", - "backup": "Sauvegarder", - "backup_complete": "Sauvegarde terminée", - "backup_failed": "Échec de la sauvegarde", - "quit": "Quitter", - "cancel": "Annuler", - "save": "Enregistrer", - "help": "Aide", - "saved": "Enregistré", - "saved_to": "Enregistré dans", - "documentation": "Documentation", - "couldnt_open": "Impossible d'ouvrir", - "report_a_bug": "Signaler un bug", - "version": "Version", - "update": "Mise à jour", - "check_for_updates": "Rechercher des mises à jour", - "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n", - "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide", - "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n", - "there_is_a_new_version_available": "Une nouvelle version est disponible:\n", - "download_the_appimage": "Télécharger l'AppImage ?", - "downloading": "Téléchargement en cours", - "download_cancelled": "Téléchargement annulé", - "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n", - "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n", - "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.", - "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n", - "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n", - "navigate": "Naviguer", - "current": "actuel", - "selected": "sélectionné", - "find_on_page": "Rechercher dans la page", - "find_next": "Rechercher le suivant", - "find_previous": "Rechercher le précédent", - "find_bar_type_to_search": "Tapez pour rechercher", - "find_bar_match_case": "Respecter la casse", - "history_dialog_preview": "Aperçu", - "history_dialog_diff": "Différences", - "history_dialog_revert_to_selected": "Revenir à la sélection", - "history_dialog_revert_failed": "Échec de la restauration", - "history_dialog_delete": "Supprimer la révision", - "history_dialog_delete_failed": "Impossible de supprimer la révision", - "key_prompt_enter_key": "Saisir la clé", - "lock_overlay_locked": "Verrouillé", - "lock_overlay_unlock": "Déverrouiller", - "main_window_lock_screen_accessibility": "&Verrouiller l'écran", - "main_window_ready": "Prêt", - "main_window_save_a_version": "Enregistrer une version", - "main_window_settings_accessible_flag": "&Paramètres", - "set_an_encryption_key": "Définir une clé de chiffrement", - "set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !", - "unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré", - "unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin", - "open_in_new_tab": "Ouvrir dans un nouvel onglet", - "autosave": "enregistrement automatique", - "unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant", - "move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable", - "insert_images": "Insérer des images", - "images": "Images", - "reopen_failed": "Échec de la réouverture", - "unlock_failed": "Échec du déverrouillage", - "could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.", - "unencrypted_export": "Export non chiffré", - "unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.", - "unrecognised_extension": "Extension non reconnue !", - "backup_encrypted_notebook": "Sauvegarder le bouquin chiffré", - "enter_a_name_for_this_version": "Saisir un nom pour cette version", - "new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à", - "appearance": "Apparence", - "security": "Sécurité", - "features": "Fonctionnalités", - "database": "Base de données", - "save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.", - "lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité", - "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.", - "font_size": "Taille de police", - "font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.", - "search_for_notes_here": "Recherchez des notes ici", - "toolbar_format": "Format", - "toolbar_bold": "Gras", - "toolbar_italic": "Italique", - "toolbar_strikethrough": "Barré", - "toolbar_normal_paragraph_text": "Texte de paragraphe normal", - "toolbar_font_smaller": "Texte plus petit", - "toolbar_font_larger": "Texte plus grand", - "toolbar_bulleted_list": "Liste à puces", - "toolbar_numbered_list": "Liste numérotée", - "toolbar_code_block": "Bloc de code", - "toolbar_heading": "Titre", - "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", - "tags": "Étiquettes", - "tag": "Étiquette", - "manage_tags": "Gérer les étiquettes", - "add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée", - "tag_browser_title": "Navigateur d'étiquettes", - "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", - "color_hex": "Couleur", - "date": "Date", - "add_a_tag": "Ajouter une étiquette", - "edit_tag_name": "Modifier le nom de l'étiquette", - "new_tag_name": "Nouveau nom de l'étiquette :", - "change_color": "Changer la couleur", - "delete_tag": "Supprimer l'étiquette", - "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", - "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", - "statistics": "Statistiques", - "main_window_statistics_accessible_flag": "Stat&istiques", - "stats_pages_with_content": "Pages avec contenu (version actuelle)", - "stats_total_revisions": "Nombre total de révisions", - "stats_page_most_revisions": "Page avec le plus de révisions", - "stats_total_words": "Nombre total de mots (versions actuelles)", - "stats_unique_tags": "Étiquettes uniques", - "stats_page_most_tags": "Page avec le plus d'étiquettes", - "stats_activity_heatmap": "Carte de chaleur d'activité", - "stats_heatmap_metric": "Colorer selon", - "stats_metric_words": "Mots", - "stats_metric_revisions": "Révisions", - "stats_no_data": "Aucune statistique disponible pour le moment.", - "select_notebook": "Sélectionner un bouquin", - "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.", - "bug_report_placeholder": "Saisissez votre rapport de bug ici", - "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.", - "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.", - "bug_report_sent_ok": "Rapport de bug envoyé. Merci !", - "send": "Envoyer", - "reminder": "Rappel", - "set_reminder": "Définir le rappel", - "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !", - "invalid_time_title": "Heure invalide", - "invalid_time_message": "Veuillez saisir une heure au format HH:MM", - "dismiss": "Ignorer", - "toolbar_alarm": "Régler l'alarme de rappel", - "activities": "Activités", - "activity": "Activité", - "note": "Note", - "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité", - "activity_delete_error_title": "Problème lors de la suppression de l'activité", - "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité", - "activity_rename_error_title": "Problème lors du renommage de l'activité", - "activity_required_message": "Un nom d'activité est requis", - "activity_required_title": "Nom d'activité requis", - "add_activity": "Ajouter une activité", - "add_project": "Ajouter un projet", - "add_time_entry": "Ajouter une entrée de temps", - "time_period": "Période", - "by_day": "par jour", - "by_month": "par mois", - "by_week": "par semaine", - "date_range": "Plage de dates", - "delete_activity": "Supprimer l'activité", - "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?", - "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?", - "delete_project": "Supprimer le projet", - "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?", - "delete_project_title": "Supprimer le projet - êtes-vous sûr ?", - "delete_time_entry": "Supprimer l'entrée de temps", - "group_by": "Grouper par", - "hours": "Heures", - "invalid_activity_message": "L'activité est invalide", - "invalid_activity_title": "Activité invalide", - "invalid_project_message": "Le projet est invalide", - "invalid_project_title": "Projet invalide", - "manage_activities": "Gérer les activités", - "manage_projects": "Gérer les projets", - "manage_projects_activities": "Gérer les activités du projet", - "open_time_log": "Ouvrir le journal de temps", - "project": "Projet", - "project_delete_error_message": "Un problème est survenu lors de la suppression du projet", - "project_delete_error_title": "Problème lors de la suppression du projet", - "project_rename_error_message": "Un problème est survenu lors du renommage du projet", - "project_rename_error_title": "Problème lors du renommage du projet", - "project_required_message": "Un projet est requis", - "project_required_title": "Projet requis", - "projects": "Projets", - "rename_activity": "Renommer l'activité", - "rename_project": "Renommer le projet", - "run_report": "Exécuter le rapport", - "add_activity_title": "Ajouter une activité", - "add_activity_label": "Ajouter une activité", - "rename_activity_label": "Renommer l'activité", - "add_project_title": "Ajouter un projet", - "add_project_label": "Ajouter un projet", - "rename_activity_title": "Renommer cette activité", - "rename_project_label": "Renommer le projet", - "rename_project_title": "Renommer ce projet", - "select_activity_message": "Sélectionner une activité", - "select_activity_title": "Sélectionner une activité", - "select_project_message": "Sélectionner un projet", - "select_project_title": "Sélectionner un projet", - "time_log": "Journal de temps", - "time_log_collapsed_hint": "Journal de temps", - "time_log_date_label": "Date du journal de temps : {date}", - "time_log_for": "Journal de temps pour {date}", - "time_log_no_date": "Journal de temps", - "time_log_no_entries": "Aucune entrée de temps pour l'instant", - "time_log_report": "Rapport de temps", - "time_log_report_title": "Journal de temps pour {project}", - "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}", - "time_log_total_hours": "Total pour la journée : {hours:.2f}h", - "time_log_with_total": "Journal de temps ({hours:.2f}h)", - "update_time_entry": "Mettre à jour l'entrée de temps", - "time_report_total": "Total : {hours:.2f} heures", - "no_report_title": "Aucun rapport", - "no_report_message": "Veuillez exécuter un rapport avant d'exporter.", - "total": "Total", - "export_csv": "Exporter en CSV", - "export_csv_error_title": "Échec de l'exportation", - "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}", - "export_pdf": "Exporter en PDF", - "export_pdf_error_title": "Échec de l'exportation PDF", - "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}", - "enable_tags_feature": "Activer les étiquettes", - "enable_time_log_feature": "Activer le journal de temps", - "enable_reminders_feature": "Activer les rappels", - "pomodoro_time_log_default_text": "Session de concentration", - "toolbar_pomodoro_timer": "Minuteur de suivi du temps", - "set_code_language": "Définir le langage du code", - "cut": "Couper", - "copy": "Copier", - "paste": "Coller", - "start": "Démarrer", - "pause": "Pause", - "resume": "Reprendre", - "stop_and_log": "Arrêter et enregistrer", - "once": "une fois", - "daily": "quotidien", - "weekdays": "jours de semaine", - "weekly": "hebdomadaire", - "edit_reminder": "Modifier le rappel", - "time": "Heure", - "once_today": "Une fois (aujourd'hui)", - "every_day": "Tous les jours", - "every_weekday": "Tous les jours de semaine (lun-ven)", - "every_week": "Toutes les semaines", - "repeat": "Répéter", - "monday": "Lundi", - "tuesday": "Mardi", - "wednesday": "Mercredi", - "thursday": "Jeudi", - "friday": "Vendredi", - "saturday": "Samedi", - "sunday": "Dimanche", - "day": "Jour" -} diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json deleted file mode 100644 index 6be0955..0000000 --- a/bouquin/locales/it.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "db_sqlcipher_integrity_check_failed": "Controllo di integrità SQLCipher fallito", - "db_issues_reported": "problema/i segnalato/i", - "db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave", - "db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata", - "db_database_error": "Errore del database", - "db_key_incorrect": "La chiave è probabilmente errata", - "database_maintenance": "Manutenzione del database", - "database_compact": "Compatta il database", - "database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.", - "database_compacted_successfully": "Database compattato con successo!", - "encryption": "Crittografia", - "remember_key": "Ricorda la chiave", - "change_encryption_key": "Cambia chiave di crittografia", - "enter_a_new_encryption_key": "Inserisci una nuova chiave di crittografia", - "reenter_the_new_key": "Reinserisci la nuova chiave", - "key_mismatch": "Le chiavi non corrispondono", - "key_mismatch_explanation": "Le due chiavi inserite non corrispondono.", - "empty_key": "Chiave vuota", - "empty_key_explanation": "La chiave non può essere vuota.", - "key_changed": "Chiave cambiata", - "key_changed_explanation": "Il blocco note è stato criptato nuovamente con la nuova chiave!", - "error": "Errore", - "success": "Successo", - "close": "Chiudi", - "find": "Trova", - "file": "File", - "locale": "Lingua", - "locale_restart": "Per favore riavvia l'applicazione per caricare la nuova lingua.", - "settings": "Impostazioni", - "theme": "Tema", - "system": "Sistema", - "light": "Chiaro", - "dark": "Scuro", - "never": "Mai", - "previous": "Precedente", - "previous_day": "Giorno precedente", - "next": "Successivo", - "next_day": "Giorno successivo", - "today": "Oggi", - "show": "Mostra", - "history": "Cronologia", - "export_accessible_flag": "&Esporta", - "export_entries": "Esporta voci", - "export_complete": "Esportazione completata", - "export_failed": "Esportazione fallita", - "backup": "Backup", - "backup_complete": "Backup completato", - "backup_failed": "Backup fallito", - "quit": "Esci", - "help": "Aiuto", - "saved": "Salvato", - "saved_to": "Salvato in", - "documentation": "Documentazione", - "couldnt_open": "Impossibile aprire", - "report_a_bug": "Segnala un bug", - "version": "Versione", - "navigate": "Naviga", - "current": "corrente", - "selected": "selezionato", - "find_on_page": "Trova nella pagina", - "find_next": "Trova successivo", - "find_previous": "Trova precedente", - "find_bar_type_to_search": "Digita per cercare", - "find_bar_match_case": "Distingui maiuscole/minuscole", - "history_dialog_preview": "Anteprima", - "history_dialog_diff": "Differenze", - "history_dialog_revert_to_selected": "Ripristina alla versione selezionata", - "history_dialog_revert_failed": "Ripristino fallito", - "key_prompt_enter_key": "Inserisci la chiave", - "lock_overlay_locked": "Bloccato", - "lock_overlay_unlock": "Sblocca", - "main_window_ready": "Pronto", - "main_window_save_a_version": "Salva versione", - "main_window_settings_accessible_flag": "Impo&stazioni", - "set_an_encryption_key": "Imposta una chiave di crittografia", - "set_an_encryption_key_explanation": "Bouquin cripta i tuoi dati.\n\nCrea una passphrase sicura per criptare il blocco note.\n\nPuoi sempre cambiarla in seguito!", - "unlock_encrypted_notebook": "Sblocca il blocco note criptato", - "unlock_encrypted_notebook_explanation": "Inserisci la chiave per sbloccare il blocco note", - "open_in_new_tab": "Apri in una nuova scheda", - "autosave": "salvataggio automatico", - "unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo", - "move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio", - "insert_images": "Inserisci immagini", - "images": "Immagini", - "reopen_failed": "Riapertura fallita", - "unlock_failed": "Sblocco fallito", - "could_not_unlock_database_at_new_path": "Impossibile sbloccare il database nel nuovo percorso.", - "unencrypted_export": "Esportazione non criptata", - "unencrypted_export_warning": "L'esportazione del database sarà non criptata!\nVuoi davvero continuare?\nSe desideri un backup criptato, scegli Backup invece di Esporta.", - "unrecognised_extension": "Estensione non riconosciuta!", - "backup_encrypted_notebook": "Backup del blocco note criptato", - "enter_a_name_for_this_version": "Inserisci un nome per questa versione", - "new_version_i_saved_at": "Nuova versione salvata il", - "save_key_warning": "Se non vuoi che ti venga richiesta la chiave di crittografia, seleziona questa opzione per ricordarla.\nATTENZIONE: la chiave viene salvata sul disco e potrebbe essere recuperabile se il disco fosse compromesso.", - "lock_screen_when_idle": "Blocca lo schermo quando inattivo", - "autolock_explanation": "Bouquin bloccherà automaticamente il blocco note dopo questo intervallo di tempo, dopodiché sarà necessario reinserire la chiave per sbloccarlo.\nImposta a 0 (mai) per non bloccarlo mai.", - "search_for_notes_here": "Cerca note qui", - "toolbar_format": "Formato", - "toolbar_bold": "Grassetto", - "toolbar_italic": "Corsivo", - "toolbar_strikethrough": "Barrato", - "toolbar_normal_paragraph_text": "Testo normale", - "toolbar_bulleted_list": "Elenco puntato", - "toolbar_numbered_list": "Elenco numerato", - "toolbar_code_block": "Blocco di codice", - "toolbar_heading": "Titolo", - "toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo", - "tags": "Tag", - "manage_tags": "Gestisci tag", - "add_tag_placeholder": "Aggiungi un tag e premi Invio", - "tag_browser_title": "Browser dei tag", - "tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.", - "color_hex": "Colore", - "date": "Data", - "add_a_tag": "Aggiungi un tag", - "edit_tag_name": "Modifica nome tag", - "new_tag_name": "Nuovo nome tag:", - "change_color": "Cambia colore", - "delete_tag": "Elimina tag", - "delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.", - "tag_already_exists_with_that_name": "Esiste già un tag con questo nome", - "cancel": "Annulla", - "select_notebook": "Seleziona blocco note", - "save": "Salva", - "history_dialog_delete": "Cancella versione", - "check_for_updates": "Controlla aggiornamenti", - "close": "Chiudi", - "send": "Invia", - "time_log": "Registro Attività", - "time_log_no_entries": "Nessuna Attività", - "close_tab": "Chiudi scheda", - "toolbar_font_smaller": "Rimpicciolisci testo", - "toolbar_font_larger": "Ingrandisci testo", - "toolbar_alarm": "Imposta promemoria", - "statistics": "Statistiche", - "main_window_statistics_accessible_flag": "Stat&istiche", - "main_window_lock_screen_accessibility": "B&locca Schermo", - "font_size": "Dimensione carattere", - "font_size_explanation": "Cambiare questo valore camberà la dimensione di tutto il testo in tutte le schede. Dimensione di titoli e blocchi di codice rimarranno invariati", - "enable_tags_feature": "Abilita Tags", - "enable_time_log_feature": "Abilita Traccuamento del tempo", - "appearance": "Interfaccia", - "features": "Funzionalità", - "security": "Sicurezza", - "bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.", - "bug_report_placeholder": "Scrivi la tua segnalazione qui", - "update": "Aggiornamento", - "you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n", - "cut": "Taglia", - "copy": "Copia", - "paste": "Incolla", - "monday": "Lunedì", - "tuesday": "Martedì", - "wednesday": "Mercoledì", - "thursday": "Giovedì", - "friday": "Venerdì", - "saturday": "Sabato", - "sunday": "Domenica", - "day": "Giorno" -} diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index 4a1a98e..5d7d40a 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -1,14 +1,12 @@ from __future__ import annotations from PySide6.QtCore import Qt, QEvent +from PySide6.QtGui import QPalette from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton -from . import strings -from .theme import ThemeManager - class LockOverlay(QWidget): - def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager): + def __init__(self, parent: QWidget, on_unlock: callable): """ Widget that 'locks' the screen after a configured idle time. """ @@ -18,16 +16,18 @@ class LockOverlay(QWidget): self.setFocusPolicy(Qt.StrongFocus) self.setGeometry(parent.rect()) + self._styling = False # <-- reentrancy guard + self._last_dark: bool | None = None + lay = QVBoxLayout(self) lay.addStretch(1) - msg = QLabel(strings._("lock_overlay_locked"), self) + msg = QLabel("Locked due to inactivity", self) msg.setObjectName("lockLabel") msg.setAlignment(Qt.AlignCenter) - self._btn = QPushButton(strings._("lock_overlay_unlock"), self) + self._btn = QPushButton("Unlock", self) self._btn.setObjectName("unlockButton") - self._btn.setShortcut("Ctrl+Shift+U") self._btn.setFixedWidth(200) self._btn.setCursor(Qt.PointingHandCursor) self._btn.setAutoDefault(True) @@ -38,9 +38,91 @@ class LockOverlay(QWidget): lay.addWidget(self._btn, 0, Qt.AlignCenter) lay.addStretch(1) - themes.register_lock_overlay(self) + self._apply_overlay_style() self.hide() + def _is_dark(self, pal: QPalette) -> bool: + """ + Detect if dark mode is in use. + """ + c = pal.color(QPalette.Window) + luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF() + return luma < 0.5 + + def _apply_overlay_style(self): + if self._styling: + return + dark = self._is_dark(self.palette()) + if dark == self._last_dark: + return + self._styling = True + try: + if dark: + link = self.palette().color(QPalette.Link) + accent_hex = link.name() # e.g. "#FFA500" + r, g, b = link.red(), link.green(), link.blue() + + self.setStyleSheet( + f""" +#LockOverlay {{ background-color: rgb(0,0,0); }} +#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }} + +#LockOverlay QPushButton#unlockButton {{ + color: {accent_hex}; + background-color: rgba({r},{g},{b},0.10); + border: 1px solid {accent_hex}; + border-radius: 8px; + padding: 8px 16px; +}} +#LockOverlay QPushButton#unlockButton:hover {{ + background-color: rgba({r},{g},{b},0.16); + border-color: {accent_hex}; +}} +#LockOverlay QPushButton#unlockButton:pressed {{ + background-color: rgba({r},{g},{b},0.24); +}} +#LockOverlay QPushButton#unlockButton:focus {{ + outline: none; + border-color: {accent_hex}; +}} + """ + ) + else: + # (light mode unchanged) + self.setStyleSheet( + """ +#LockOverlay { background-color: rgba(0,0,0,120); } +#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; } +#LockOverlay QPushButton#unlockButton { + color: palette(button-text); + background-color: rgba(255,255,255,0.92); + border: 1px solid rgba(0,0,0,0.25); + border-radius: 8px; + padding: 8px 16px; +} +#LockOverlay QPushButton#unlockButton:hover { + background-color: rgba(255,255,255,1.0); + border-color: rgba(0,0,0,0.35); +} +#LockOverlay QPushButton#unlockButton:pressed { + background-color: rgba(245,245,245,1.0); +} +#LockOverlay QPushButton#unlockButton:focus { + outline: none; + border-color: palette(highlight); +} + """ + ) + self._last_dark = dark + finally: + self._styling = False + + def changeEvent(self, ev): + super().changeEvent(ev) + # Only re-style on palette flips (user changed theme) + if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange): + self._apply_overlay_style() + def eventFilter(self, obj, event): if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show): self.setGeometry(obj.rect()) diff --git a/bouquin/main.py b/bouquin/main.py index 958185d..a481480 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -1,25 +1,17 @@ from __future__ import annotations import sys -from pathlib import Path from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QIcon from .settings import APP_NAME, APP_ORG, get_settings from .main_window import MainWindow from .theme import Theme, ThemeConfig, ThemeManager -from . import strings def main(): app = QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setOrganizationName(APP_ORG) - # Icon - BASE_DIR = Path(__file__).resolve().parent - ICON_PATH = BASE_DIR / "icons" / "bouquin.svg" - icon = QIcon(str(ICON_PATH)) - app.setWindowIcon(icon) s = get_settings() theme_str = s.value("ui/theme", "system") @@ -27,7 +19,6 @@ def main(): themes = ThemeManager(app, cfg) themes.apply(cfg.theme) - strings.load_strings(s.value("ui/locale", "en")) win = MainWindow(themes=themes) win.show() sys.exit(app.exec()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 0e5e454..234be2b 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -15,8 +15,6 @@ from PySide6.QtCore import ( QUrl, QEvent, QSignalBlocker, - QDateTime, - QTime, ) from PySide6.QtGui import ( QAction, @@ -27,48 +25,36 @@ 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 .markdown_editor import MarkdownEditor 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 .version_check import VersionChecker +from .theme import Theme, ThemeManager class MainWindow(QMainWindow): @@ -78,7 +64,6 @@ 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): @@ -94,31 +79,16 @@ 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() @@ -126,44 +96,36 @@ 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) - # 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 + # This is the note-taking editor + self.editor = MarkdownEditor(self.themes) # Toolbar for controlling styling self.toolBar = ToolBar() self.addToolBar(self.toolBar) - self._bind_toolbar() + # Wire toolbar intents to editor methods + self.toolBar.boldRequested.connect(self.editor.apply_weight) + self.toolBar.italicRequested.connect(self.editor.apply_italic) + # Note: Markdown doesn't support underline, so we skip underlineRequested + 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) + # Note: Markdown doesn't natively support alignment, removing alignRequested + self.toolBar.historyRequested.connect(self._open_history) + self.toolBar.insertImageRequested.connect(self._on_insert_image) - # Create the first editor tab - self._create_new_tab() - self._prev_editor = self.editor + self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) + self.editor.cursorPositionChanged.connect(self._sync_toolbar) split = QSplitter() split.addWidget(left_panel) - split.addWidget(self.tab_widget) + split.addWidget(self.editor) 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) @@ -177,14 +139,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, themes=self.themes - ) + self._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked) 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 @@ -198,180 +160,125 @@ class MainWindow(QMainWindow): ) # Status bar for feedback - self.statusBar().showMessage(strings._("main_window_ready"), 800) + self.statusBar().showMessage("Ready", 800) # Add findBar and add it to the statusBar - # FindBar will get the current editor dynamically via a callable - self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) + self.findBar = FindBar(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("&" + strings._("file")) - act_save = QAction("&" + strings._("main_window_save_a_version"), self) + file_menu = mb.addMenu("&File") + act_save = QAction("&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("&" + strings._("history"), self) - act_history.setShortcut("Ctrl+Shift+H") + act_history = QAction("History", self) + act_history.setShortcut("Ctrl+H") act_history.setShortcutContext(Qt.ApplicationShortcut) act_history.triggered.connect(self._open_history) file_menu.addAction(act_history) - act_settings = QAction(strings._("main_window_settings_accessible_flag"), self) - act_settings.setShortcut("Ctrl+Shift+.") + act_settings = QAction("Settin&gs", self) + act_settings.setShortcut("Ctrl+G") act_settings.triggered.connect(self._open_settings) file_menu.addAction(act_settings) - act_export = QAction(strings._("export_accessible_flag"), self) - act_export.setShortcut("Ctrl+Shift+E") + act_export = QAction("&Export", self) + act_export.setShortcut("Ctrl+E") act_export.triggered.connect(self._export) file_menu.addAction(act_export) - act_backup = QAction("&" + strings._("backup"), self) + act_backup = QAction("&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("&" + strings._("quit"), self) + act_quit = QAction("&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("&" + strings._("navigate")) - act_prev = QAction(strings._("previous_day"), self) + nav_menu = mb.addMenu("&Navigate") + act_prev = QAction("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(strings._("next_day"), self) + act_next = QAction("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(strings._("today"), self) + act_today = QAction("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_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 = QAction("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(strings._("find_next"), self) + act_find_next = QAction("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(strings._("find_previous"), self) + act_find_prev = QAction("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("&" + strings._("help")) - act_docs = QAction(strings._("documentation"), self) - act_docs.setShortcut("Ctrl+Shift+D") + help_menu = mb.addMenu("&Help") + act_docs = QAction("Documentation", self) + act_docs.setShortcut("Ctrl+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(strings._("report_a_bug"), self) - act_bugs.setShortcut("Ctrl+Shift+R") + act_bugs = QAction("Report a bug", self) + act_bugs.setShortcut("Ctrl+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) - - # Reminders / alarms - self._reminder_timers: list[QTimer] = [] + self.editor.textChanged.connect(self._on_text_changed) # First load + mark dates in calendar with content - if not self._load_unchecked_todos(): + if not self._load_yesterday_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. @@ -379,652 +286,105 @@ 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 = strings._("db_key_incorrect") + error = "The key is probably incorrect." else: error = str(e) - QMessageBox.critical(self, strings._("db_database_error"), error) + QMessageBox.critical(self, "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 = strings._("set_an_encryption_key") - message = strings._("set_an_encryption_key_explanation") + 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!" else: - title = strings._("unlock_encrypted_notebook") - message = strings._("unlock_encrypted_notebook_explanation") + title = "Unlock encrypted notebook" + message = "Enter your key to unlock the notebook" while True: - dlg = KeyPrompt( - self, title, message, initial_db_path=self.cfg.path, show_db_change=True - ) + dlg = KeyPrompt(self, title, message) 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() @@ -1034,8 +394,6 @@ 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 []: @@ -1076,7 +434,7 @@ class MainWindow(QMainWindow): fmt.setFontWeight(QFont.Weight.Normal) # remove bold only self.calendar.setDateTextFormat(d, fmt) self._marked_dates = set() - if self.db.conn is not None: + try: for date_iso in self.db.dates_with_content(): qd = QDate.fromString(date_iso, "yyyy-MM-dd") if qd.isValid(): @@ -1084,44 +442,10 @@ 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 ------------------- # - - 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 + # --- UI handlers --------------------------------------------------------- def _sync_toolbar(self): fmt = self.editor.currentCharFormat() @@ -1165,193 +489,168 @@ class MainWindow(QMainWindow): self.toolBar.actBullets.setChecked(bool(bullets_on)) self.toolBar.actNumbers.setChecked(bool(numbers_on)) - 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) - - # Apply font size change to all open editors - self._apply_font_size_to_all_tabs(new_size) - - 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) - - 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 - - # 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") - - # Get current date - date_iso = self.editor.current_date.toString("yyyy-MM-dd") - - # Start the timer - self.pomodoro_manager.start_timer_for_line(line_text, date_iso) - - 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) - - # Try to bring the window to the front - self.showNormal() - self.raise_() - self.activateWindow() - - # Simple dialog with a flashing background to reinforce the alert - dlg = QDialog(self) - dlg.setWindowTitle(strings._("reminder")) - dlg.setModal(True) - dlg.setMinimumWidth(400) - - layout = QVBoxLayout(dlg) - label = QLabel(text) - label.setWordWrap(True) - layout.addWidget(label) - - btn = QPushButton(strings._("dismiss")) - btn.clicked.connect(dlg.accept) - layout.addWidget(btn) - - flash_timer = QTimer(dlg) - flash_state = {"on": False} - - def toggle(): - flash_state["on"] = not flash_state["on"] - if flash_state["on"]: - dlg.setStyleSheet("background-color: #3B3B3B;") - else: - dlg.setStyleSheet("") - - flash_timer.timeout.connect(toggle) - flash_timer.start(500) # ms - - 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): - """ - Scan the markdown for today's date and create QTimers - only for alarms on the *current day* (system date). - """ - # We only ever set timers for the real current date - today = QDate.currentDate() - today_iso = today.toString("yyyy-MM-dd") - - # Clear any previously scheduled "today" reminders - self._clear_reminder_timers() - - # 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: - # 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): - if hasattr(self.editor, "current_date"): - date_iso = self.editor.current_date.toString("yyyy-MM-dd") - else: + 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: + # 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) + except Exception as e: + QMessageBox.critical(self, "Read Error", str(e)) + return + + self._set_editor_markdown_preserve_view(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) + + 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.calendar.setSelectedDate(today) + + 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 = [] + + # Split into lines and find unchecked checkbox items + lines = text.split("\n") + remaining_lines = [] + + for line in lines: + # Check for unchecked markdown checkboxes: - [ ] or - [☐] + if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( + r"^\s*-\s*\[☐\]\s+", line + ): + # Extract the text after the checkbox + item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) + unchecked_items.append(f"- [ ] {item_text}") + else: + # Keep all other lines + remaining_lines.append(line) + + # Save modified content back if we moved items + if unchecked_items: + modified_text = "\n".join(remaining_lines) + self.db.save_new_version( + yesterday_str, + modified_text, + "Unchecked checkbox items moved to next day", + ) + + # Join unchecked items into markdown format + unchecked_str = "\n".join(unchecked_items) + "\n" + + # Load the unchecked items into the current editor + self._load_selected_date(False, unchecked_str) + else: + return False + + except Exception as e: + raise SystemError(e) + + 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. + """ + # 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() + + 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_markdown() + 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 + + 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() + 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 + + def _open_history(self): + 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, - strings._("insert_images"), + "Insert image(s)", "", - strings._("images") + "(*.png *.jpg *.jpeg *.bmp *.gif *.webp)", + "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)", ) if not paths: return @@ -1359,61 +658,6 @@ class MainWindow(QMainWindow): 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): dlg = SettingsDialog(self.cfg, self.db, self) @@ -1429,64 +673,24 @@ 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, - strings._("reopen_failed"), - strings._("could_not_unlock_database_at_new_path"), + self, "Reopen failed", "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) @@ -1524,8 +728,14 @@ class MainWindow(QMainWindow): # ----------------- Export handler ----------------- # @Slot() def _export(self): - warning_title = strings._("unencrypted_export") - warning_message = strings._("unencrypted_export_warning") + 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. +""" dlg = QMessageBox() dlg.setWindowTitle(warning_title) dlg.setText(warning_message) @@ -1537,6 +747,7 @@ class MainWindow(QMainWindow): return False filters = ( + "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;" @@ -1546,25 +757,28 @@ class MainWindow(QMainWindow): start_dir = os.path.join(os.path.expanduser("~"), "Documents") filename, selected_filter = QFileDialog.getSaveFileName( - self, strings._("export_entries"), start_dir, filters + self, "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, ".md") + }.get(selected_filter, ".txt") if not Path(filename).suffix: filename += default_ext try: entries = self.db.get_all_entries() - if selected_filter.startswith("JSON"): + if selected_filter.startswith("Text"): + self.db.export_txt(entries, filename) + elif selected_filter.startswith("JSON"): self.db.export_json(entries, filename) elif selected_filter.startswith("CSV"): self.db.export_csv(entries, filename) @@ -1575,15 +789,11 @@ class MainWindow(QMainWindow): elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: - raise ValueError(strings._("unrecognised_extension")) + self.db.export_by_extension(filename) - QMessageBox.information( - self, - strings._("export_complete"), - strings._("saved_to") + f" {filename}", - ) + QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}") except Exception as e: - QMessageBox.critical(self, strings._("export_failed"), str(e)) + QMessageBox.critical(self, "Export failed", str(e)) # ----------------- Backup handler ----------------- # @Slot() @@ -1595,7 +805,7 @@ class MainWindow(QMainWindow): os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db" ) filename, selected_filter = QFileDialog.getSaveFileName( - self, strings._("backup_encrypted_notebook"), start_dir, filters + self, "Backup encrypted notebook", start_dir, filters ) if not filename: return # user cancelled @@ -1611,12 +821,10 @@ class MainWindow(QMainWindow): if selected_filter.startswith("SQL"): self.db.export_sqlcipher(filename) QMessageBox.information( - self, - strings._("backup_complete"), - strings._("saved_to") + f" {filename}", + self, "Backup complete", f"Saved to:\n{filename}" ) except Exception as e: - QMessageBox.critical(self, strings._("backup_failed"), str(e)) + QMessageBox.critical(self, "Backup failed", str(e)) # ----------------- Help handlers ----------------- # @@ -1625,17 +833,16 @@ class MainWindow(QMainWindow): url = QUrl.fromUserInput(url_str) if not QDesktopServices.openUrl(url): QMessageBox.warning( - self, - strings._("documentation"), - strings._("couldnt_open") + url.toDisplayString(), + self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" ) def _open_bugs(self): - dlg = BugReportDialog(self) - dlg.exec() - - def _open_version(self): - self.version_checker.show_version_dialog() + 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()}" + ) # ----------------- Idle handlers ----------------- # def _apply_idle_minutes(self, minutes: int): @@ -1646,27 +853,22 @@ class MainWindow(QMainWindow): self._idle_timer.stop() # If currently locked, unlock when user disables the timer: if getattr(self, "_locked", False): - self._locked = False - if hasattr(self, "_lock_overlay"): - self._lock_overlay.hide() + try: + self._locked = False + if hasattr(self, "_lock_overlay"): + self._lock_overlay.hide() + except Exception: + pass 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): @@ -1680,15 +882,11 @@ class MainWindow(QMainWindow): 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): @@ -1699,7 +897,7 @@ class MainWindow(QMainWindow): try: ok = self._prompt_for_key_until_valid(first_time=False) except Exception as e: - QMessageBox.critical(self, strings._("unlock_failed"), str(e)) + QMessageBox.critical(self, "Unlock failed", str(e)) return if ok: self._locked = False @@ -1708,50 +906,25 @@ class MainWindow(QMainWindow): 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): - # 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 + 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()) + # 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 ----------------- # @@ -1768,12 +941,8 @@ class MainWindow(QMainWindow): QTimer.singleShot( 0, lambda: ( - ( - self.editor.setFocus(Qt.ActiveWindowFocusReason) - if self.editor - else None - ), - self.editor.ensureCursorVisible() if self.editor else None, + self.editor.setFocus(Qt.ActiveWindowFocusReason), + self.editor.ensureCursorVisible(), ), ) @@ -1787,3 +956,39 @@ class MainWindow(QMainWindow): super().changeEvent(ev) if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now) + + def _set_editor_markdown_preserve_view(self, markdown: 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.to_markdown() != markdown: + ed.from_markdown(markdown) + ed.blockSignals(False) + + # Restore scroll first + ed.verticalScrollBar().setValue(v) + ed.horizontalScrollBar().setValue(h) + + # Restore caret/selection (bounded to new doc length) + doc_length = ed.document().characterCount() - 1 + old_pos = min(old_pos, doc_length) + old_anchor = min(old_anchor, doc_length) + + 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 index 9f48858..ce73b6f 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -5,24 +5,199 @@ import re from pathlib import Path from PySide6.QtGui import ( + QColor, QFont, QFontDatabase, - QFontMetrics, QImage, + QPalette, + QGuiApplication, QTextCharFormat, QTextCursor, QTextDocument, - QTextFormat, - QTextBlockFormat, + QSyntaxHighlighter, QTextImageFormat, - QDesktopServices, ) -from PySide6.QtCore import Qt, QRect, QTimer, QUrl +from PySide6.QtCore import Qt from PySide6.QtWidgets import QTextEdit -from .theme import ThemeManager -from .markdown_highlighter import MarkdownHighlighter -from . import strings +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): + super().__init__(document) + self.theme_manager = theme_manager + self._setup_formats() + # Recompute formats whenever the app theme changes + try: + self.theme_manager.themeChanged.connect(self._on_theme_changed) + except Exception: + pass + + def _on_theme_changed(self, *_): + 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) + + # Strikethrough: ~~text~~ + self.strike_format = QTextCharFormat() + self.strike_format.setFontStrikeOut(True) + + # 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: + # 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 = pal.color(QPalette.Text) + 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) + + # Markdown syntax (the markers themselves) - make invisible + self.syntax_format = QTextCharFormat() + # Make the markers invisible by setting font size to 0.1 points + self.syntax_format.setFontPointSize(0.1) + # Also make them very faint in case they still show + self.syntax_format.setForeground(QColor(250, 250, 250)) + + def highlightBlock(self, text: str): + """Apply formatting to a block of text based on markdown syntax.""" + if not text: + return + + # 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("```"): + # Toggle code block state + in_code_block = not in_code_block + self.setCurrentBlockState(1 if in_code_block else 0) + # Format the fence markers - but keep them somewhat visible for editing + # Use code format instead of syntax format so cursor is visible + self.setFormat(0, len(text), self.code_block_format) + return + + if in_code_block: + # Format entire line as code + self.setFormat(0, len(text), self.code_block_format) + self.setCurrentBlockState(1) + return + + self.setCurrentBlockState(0) + + # 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: **text** or __text__ + for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text): + start, end = match.span() + content_start = start + 2 + content_end = end - 2 + + # Gray out the markers + self.setFormat(start, 2, self.syntax_format) + self.setFormat(end - 2, 2, self.syntax_format) + + # Bold the content + self.setFormat(content_start, content_end - content_start, self.bold_format) + + # Italic: *text* or _text_ (but not part of bold) + for match in re.finditer( + r"(? 0 and text[start - 1 : start + 1] in ("**", "__"): + continue + if end < len(text) and text[end : end + 1] in ("*", "_"): + continue + + content_start = start + 1 + content_end = end - 1 + + # Gray out markers + self.setFormat(start, 1, self.syntax_format) + self.setFormat(end - 1, 1, self.syntax_format) + + # Italicize content + self.setFormat( + content_start, content_end - content_start, self.italic_format + ) + + # Strikethrough: ~~text~~ + for match in re.finditer(r"~~(.+?)~~", text): + start, end = match.span() + content_start = start + 2 + content_end = end - 2 + + self.setFormat(start, 2, self.syntax_format) + self.setFormat(end - 2, 2, self.syntax_format) + self.setFormat( + content_start, content_end - content_start, self.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) class MarkdownEditor(QTextEdit): @@ -30,6 +205,12 @@ class MarkdownEditor(QTextEdit): _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + # Checkbox characters (Unicode for display, markdown for storage) + _CHECK_UNCHECKED_DISPLAY = "☐" + _CHECK_CHECKED_DISPLAY = "☑" + _CHECK_UNCHECKED_STORAGE = "[ ]" + _CHECK_CHECKED_STORAGE = "[x]" + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) @@ -42,49 +223,8 @@ class MarkdownEditor(QTextEdit): # 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() + self.highlighter = MarkdownHighlighter(self.document(), theme_manager) # Track current list type for smart enter handling self._last_enter_was_empty = False @@ -94,51 +234,9 @@ class MarkdownEditor(QTextEdit): # 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.""" @@ -147,177 +245,64 @@ class MarkdownEditor(QTextEdit): self._updating = True try: - c = self.textCursor() - block = c.block() - line = block.text() - pos_in_block = c.position() - block.position() + # Convert checkbox markdown to Unicode for display + cursor = self.textCursor() + pos = cursor.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 + text = self.toPlainText() - 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 + # Convert lines that START with "TODO " into an unchecked checkbox. + # Keeps any leading indentation. + todo_re = re.compile(r"(?m)^([ \t]*)TODO\s") + if todo_re.search(text): + modified_text = todo_re.sub( + lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ", + text, ) - c.setPosition(new_pos) - self.setTextCursor(c) + else: + modified_text = text + + # Replace checkbox markdown with Unicode (for display only) + modified_text = modified_text.replace( + "- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} " + ) + modified_text = modified_text.replace( + "- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} " + ) + + if modified_text != text: + # Count replacements before cursor to adjust position + text_before = text[:pos] + x_count = text_before.count("- [x] ") + space_count = text_before.count("- [ ] ") + # Each markdown checkbox -> unicode shortens by 2 chars ([x]/[ ] -> ☑/☐) + checkbox_delta = (x_count + space_count) * 2 + # Each "TODO " -> "- ☐ " shortens by 1 char + todo_count = len(list(todo_re.finditer(text_before))) + todo_delta = todo_count * 1 + new_pos = pos - checkbox_delta - todo_delta + + # Update the text + self.blockSignals(True) + self.setPlainText(modified_text) + self.blockSignals(False) + + # Restore cursor position + cursor = self.textCursor() + cursor.setPosition(max(0, min(new_pos, len(modified_text)))) + self.setTextCursor(cursor) + 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.""" + """Export current content as markdown (convert Unicode checkboxes back to 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 + text = text.replace(f"- {self._CHECK_CHECKED_DISPLAY} ", "- [x] ") + text = text.replace(f"- {self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ") return text @@ -356,56 +341,30 @@ class MarkdownEditor(QTextEdit): 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) - + """Load markdown text into the editor (convert markdown checkboxes to Unicode).""" # Convert markdown checkboxes to Unicode for display display_text = markdown_text.replace( - f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} " + "- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} " ) display_text = display_text.replace( - f"- {self._CHECK_UNCHECKED_STORAGE} ", f"{self._CHECK_UNCHECKED_DISPLAY} " + "- [ ] ", 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} ", + lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_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() @@ -423,69 +382,46 @@ class MarkdownEditor(QTextEdit): 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) + try: + # Decode base64 to image + img_bytes = base64.b64decode(b64_data) + image = QImage.fromData(img_bytes) - if image.isNull(): + 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) + + except Exception as e: + # If image fails to render, leave the markdown as-is + print(f"Failed to render image: {e}") 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). @@ -495,16 +431,12 @@ class MarkdownEditor(QTextEdit): line = line.lstrip() # Checkbox list (Unicode display format) - if line.startswith(f"{self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith( - f"{self._CHECK_CHECKED_DISPLAY} " + if line.startswith(f"- {self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith( + f"- {self._CHECK_CHECKED_DISPLAY} " ): - return ("checkbox", f"{self._CHECK_UNCHECKED_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 + # Bullet list if re.match(r"^[-*+]\s", line): match = re.match(r"^([-*+]\s)", line) return ("bullet", match.group(1)) @@ -518,205 +450,9 @@ class MarkdownEditor(QTextEdit): 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() @@ -724,59 +460,11 @@ class MarkdownEditor(QTextEdit): # 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: + # If current line is opening code fence, or we're inside a code block + if current_line.strip().startswith("```") or block_state == 1: + # Just insert a regular newline - the highlighter will format it as code super().keyPressEvent(event) return @@ -798,10 +486,8 @@ class MarkdownEditor(QTextEdit): self._last_enter_was_empty = False return elif is_empty: - # First enter on empty list item - just insert newline without prefix - super().keyPressEvent(event) + # First enter on empty list item - remember this self._last_enter_was_empty = True - return else: # Not empty - continue the list self._last_enter_was_empty = False @@ -820,104 +506,35 @@ class MarkdownEditor(QTextEdit): # 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() + """Handle mouse clicks - check for checkbox clicking.""" + if event.button() == Qt.MouseButton.LeftButton: + cursor = self.cursorForPosition(event.pos()) + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + line = cursor.selectedText() - # 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 + # Check if clicking on a checkbox line + if ( + f"- {self._CHECK_UNCHECKED_DISPLAY} " in line + or f"- {self._CHECK_CHECKED_DISPLAY} " in line + ): + # Toggle the checkbox + if f"- {self._CHECK_UNCHECKED_DISPLAY} " in line: + new_line = line.replace( + f"- {self._CHECK_UNCHECKED_DISPLAY} ", + f"- {self._CHECK_CHECKED_DISPLAY} ", + ) else: - i += 1 + new_line = line.replace( + f"- {self._CHECK_CHECKED_DISPLAY} ", + f"- {self._CHECK_UNCHECKED_DISPLAY} ", + ) - # Default handling for anything else + cursor.insertText(new_line) + # Don't call super() - we handled the click + return + + # Default handling for non-checkbox clicks super().mousePressEvent(event) # ------------------------ Toolbar action handlers ------------------------ @@ -991,87 +608,25 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """Insert a fenced code block, or navigate fences without creating inline backticks.""" - c = self.textCursor() - doc = self.document() + """Insert or toggle code block.""" + cursor = self.textCursor() - 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() + if cursor.hasSelection(): + # Wrap selection in code fence + selected = cursor.selectedText() + # Note: selectedText() uses Unicode paragraph separator, replace with newline + selected = selected.replace("\u2029", "\n") + new_text = f"```\n{selected}\n```" + cursor.insertText(new_text) + else: + # Insert code block template + cursor.insertText("```\n\n```") + cursor.movePosition( + QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1 + ) + self.setTextCursor(cursor) + # Return focus to editor self.setFocus() def apply_heading(self, size: int): @@ -1121,19 +676,14 @@ class MarkdownEditor(QTextEdit): 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) + # Check if already a bullet + if line.lstrip().startswith("- ") or line.lstrip().startswith("* "): + # Remove bullet + new_line = re.sub(r"^\s*[-*]\s+", "", line) else: - new_line = f"{self._BULLET_DISPLAY} " + stripped + # Add bullet + new_line = "- " + line.lstrip() cursor.insertText(new_line) @@ -1177,18 +727,18 @@ class MarkdownEditor(QTextEdit): # Check if already has checkbox (Unicode display format) if ( - f"{self._CHECK_UNCHECKED_DISPLAY} " in line - or f"{self._CHECK_CHECKED_DISPLAY} " in line + 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+", + rf"^\s*-\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() + new_line = f"- {self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip() cursor.insertText(new_line) @@ -1200,7 +750,7 @@ class MarkdownEditor(QTextEdit): if not path.exists(): return - # Read the original image file bytes for base64 encoding + # Read the ORIGINAL image file bytes for base64 encoding with open(path, "rb") as f: img_data = f.read() @@ -1224,13 +774,17 @@ class MarkdownEditor(QTextEdit): if image.isNull(): return + # Use ORIGINAL 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(image.width()) - img_format.setHeight(image.height()) + img_format.setWidth(original_width) + img_format.setHeight(original_height) - # Add original image to document resources + # Add ORIGINAL image to document resources self.document().addResource( QTextDocument.ResourceType.ImageResource, img_format.name(), image ) @@ -1239,73 +793,3 @@ class MarkdownEditor(QTextEdit): 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 deleted file mode 100644 index f9826ff..0000000 --- a/bouquin/markdown_highlighter.py +++ /dev/null @@ -1,344 +0,0 @@ -from __future__ import annotations - -import re - -from PySide6.QtGui import ( - QColor, - QFont, - QFontDatabase, - QFontMetrics, - QGuiApplication, - QPalette, - QSyntaxHighlighter, - QTextCharFormat, - QTextDocument, -) - -from .theme import ThemeManager, Theme - - -class MarkdownHighlighter(QSyntaxHighlighter): - """Live syntax highlighter for markdown that applies formatting as you type.""" - - def __init__( - self, document: QTextDocument, theme_manager: ThemeManager, editor=None - ): - super().__init__(document) - self.theme_manager = theme_manager - self._editor = editor # Reference to the MarkdownEditor - self._setup_formats() - # Recompute formats whenever the app theme changes - self.theme_manager.themeChanged.connect(self._on_theme_changed) - - def _on_theme_changed(self, *_): - self._setup_formats() - self.rehighlight() - - def refresh_for_font_change(self) -> None: - """ - Called when the editor's base font changes (zoom / settings). - It rebuilds any formats that depend on the editor font metrics. - """ - self._setup_formats() - self.rehighlight() - - def _setup_formats(self): - """Setup text formats for different markdown elements.""" - - # Bold: **text** or __text__ - self.bold_format = QTextCharFormat() - self.bold_format.setFontWeight(QFont.Weight.Bold) - - # Italic: *text* or _text_ - self.italic_format = QTextCharFormat() - self.italic_format.setFontItalic(True) - - # Allow combination of bold/italic - self.bold_italic_format = QTextCharFormat() - self.bold_italic_format.setFontWeight(QFont.Weight.Bold) - self.bold_italic_format.setFontItalic(True) - - # Strikethrough: ~~text~~ - self.strike_format = QTextCharFormat() - self.strike_format.setFontStrikeOut(True) - - # Inline code: `code` - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) - self.code_format = QTextCharFormat() - self.code_format.setFont(mono) - self.code_format.setFontFixedPitch(True) - - # Code block: ``` - self.code_block_format = QTextCharFormat() - self.code_block_format.setFont(mono) - self.code_block_format.setFontFixedPitch(True) - - pal = QGuiApplication.palette() - if ( - self.theme_manager.current() == Theme.DARK - or self.theme_manager._is_system_dark - ): - # In dark mode, use a darker panel-like background - bg = pal.color(QPalette.AlternateBase) - fg = pal.color(QPalette.Text) - else: - # Light mode: keep the existing light gray - bg = QColor(245, 245, 245) - fg = QColor( # pragma: no cover - 0, 0, 0 - ) # avoiding using QPalette.Text as it can be white on macOS - self.code_block_format.setBackground(bg) - self.code_block_format.setForeground(fg) - - # Headings - self.h1_format = QTextCharFormat() - self.h1_format.setFontPointSize(24.0) - self.h1_format.setFontWeight(QFont.Weight.Bold) - - self.h2_format = QTextCharFormat() - self.h2_format.setFontPointSize(18.0) - self.h2_format.setFontWeight(QFont.Weight.Bold) - - self.h3_format = QTextCharFormat() - self.h3_format.setFontPointSize(14.0) - self.h3_format.setFontWeight(QFont.Weight.Bold) - - # Hyperlinks - self.link_format = QTextCharFormat() - link_color = pal.color(QPalette.Link) - self.link_format.setForeground(link_color) - self.link_format.setFontUnderline(True) - self.link_format.setAnchor(True) - - # Checkboxes - self.checkbox_format = QTextCharFormat() - self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle) - - # Bullets - self.bullet_format = QTextCharFormat() - - # Use Symbols font for checkbox and bullet glyphs if present - if self._editor is not None and hasattr(self._editor, "symbols_font_family"): - base_font = QFont(self._editor.qfont) # copy of editor font - symbols_font = QFont(self._editor.symbols_font_family) - symbols_font.setPointSizeF(base_font.pointSizeF()) - - base_metrics = QFontMetrics(base_font) - sym_metrics = QFontMetrics(symbols_font) - - # If Symbols glyphs are noticeably shorter than the text, - # scale them up so the visual heights roughly match. - if sym_metrics.height() > 0: - ratio = base_metrics.height() / sym_metrics.height() - if ratio > 1.05: # more than ~5% smaller - ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it. - symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio) - - self.checkbox_format.setFont(symbols_font) - self.bullet_format.setFont(symbols_font) - - # Markdown syntax (the markers themselves) - make invisible - self.syntax_format = QTextCharFormat() - # Use the editor background color so they blend in - bg = pal.color(QPalette.Base) - hidden = QColor(bg) - hidden.setAlpha(0) - self.syntax_format.setForeground(hidden) - # Make the markers invisible by setting font size to 0.1 points - self.syntax_format.setFontPointSize(0.1) - - def _overlay_range( - self, start: int, length: int, overlay_fmt: QTextCharFormat - ) -> None: - """Merge overlay_fmt onto the existing format for each char in [start, start+length).""" - end = start + length - i = start - while i < end: - base = QTextCharFormat(self.format(i)) # current format at this position - base.merge(overlay_fmt) # add only the properties we set - self.setFormat(i, 1, base) # write back one char - i += 1 - - def highlightBlock(self, text: str): - """Apply formatting to a block of text based on markdown syntax.""" - - # Track if we're in a code block (multiline) - prev_state = self.previousBlockState() - in_code_block = prev_state == 1 - - # Check for code block fences - if text.strip().startswith("```"): - # background for the whole fence line (so block looks continuous) - self.setFormat(0, len(text), self.code_block_format) - - # hide the three backticks themselves - idx = text.find("```") - if idx != -1: - self.setFormat(idx, 3, self.syntax_format) - - # toggle code-block state and stop; next line picks up state - in_code_block = not in_code_block - self.setCurrentBlockState(1 if in_code_block else 0) - return - - if in_code_block: - # inside code: apply block bg and language rules - self.setFormat(0, len(text), self.code_block_format) - - # Try to apply language-specific highlighting - if self._editor and hasattr(self._editor, "_code_metadata"): - from .code_highlighter import CodeHighlighter - - # Find the opening fence block - prev_block = self.currentBlock().previous() - fence_block_num = None - temp_inside = in_code_block - - while prev_block.isValid(): - if prev_block.text().strip().startswith("```"): - temp_inside = not temp_inside - if not temp_inside: - fence_block_num = prev_block.blockNumber() - break - prev_block = prev_block.previous() - - if fence_block_num is not None: - language = self._editor._code_metadata.get_language(fence_block_num) - if language: - patterns = CodeHighlighter.get_language_patterns(language) - for pattern, syntax_type in patterns: - for match in re.finditer(pattern, text): - start, end = match.span() - fmt = CodeHighlighter.get_format_for_type( - syntax_type, self.code_block_format - ) - self.setFormat(start, end - start, fmt) - - self.setCurrentBlockState(1) - return - - # ---- Normal markdown (outside code) - self.setCurrentBlockState(0) - - # If the line is empty and not in a code block, nothing else to do - if not text: - return - - # Headings (must be at start of line) - heading_match = re.match(r"^(#{1,3})\s+", text) - if heading_match: - level = len(heading_match.group(1)) - marker_len = len(heading_match.group(0)) - - # Format the # markers - self.setFormat(0, marker_len, self.syntax_format) - - # Format the heading text - heading_fmt = ( - self.h1_format - if level == 1 - else self.h2_format if level == 2 else self.h3_format - ) - self.setFormat(marker_len, len(text) - marker_len, heading_fmt) - return - - # Bold+Italic (*** or ___): do these first and record occupied spans. - # --- Triple emphasis: detect first, hide markers now, but DEFER applying content style - triple_contents: list[tuple[int, int]] = [] # (start, length) for content only - occupied: list[tuple[int, int]] = ( - [] - ) # full spans including markers, for overlap checks - - for m in re.finditer( - r"(? 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 deleted file mode 100644 index fd29742..0000000 --- a/bouquin/pomodoro_timer.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -import math -from typing import Optional - -from PySide6.QtCore import Qt, QTimer, Signal, Slot -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QWidget, -) - -from . import strings -from .db import DBManager -from .time_log import TimeLogDialog - - -class PomodoroTimer(QDialog): - """A simple timer dialog for tracking work time on a specific task.""" - - timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) - - def __init__(self, task_text: str, parent: Optional[QWidget] = None): - super().__init__(parent) - self.setWindowTitle(strings._("toolbar_pomodoro_timer")) - self.setModal(False) - self.setMinimumWidth(300) - - self._task_text = task_text - self._elapsed_seconds = 0 - self._running = False - - layout = QVBoxLayout(self) - - # Task label - task_label = QLabel(task_text) - task_label.setWordWrap(True) - layout.addWidget(task_label) - - # Timer display - self.time_label = QLabel("00:00:00") - font = self.time_label.font() - font.setPointSize(24) - font.setBold(True) - self.time_label.setFont(font) - self.time_label.setAlignment(Qt.AlignCenter) - layout.addWidget(self.time_label) - - # Control buttons - btn_layout = QHBoxLayout() - - self.start_pause_btn = QPushButton(strings._("start")) - self.start_pause_btn.clicked.connect(self._toggle_timer) - btn_layout.addWidget(self.start_pause_btn) - - self.stop_btn = QPushButton(strings._("stop_and_log")) - self.stop_btn.clicked.connect(self._stop_and_log) - self.stop_btn.setEnabled(False) - btn_layout.addWidget(self.stop_btn) - - layout.addLayout(btn_layout) - - # Internal timer (ticks every second) - self._timer = QTimer(self) - self._timer.timeout.connect(self._tick) - - @Slot() - def _toggle_timer(self): - """Start or pause the timer.""" - if self._running: - # Pause - self._running = False - self._timer.stop() - self.start_pause_btn.setText(strings._("resume")) - else: - # Start/Resume - self._running = True - self._timer.start(1000) # 1 second - self.start_pause_btn.setText(strings._("pause")) - self.stop_btn.setEnabled(True) - - @Slot() - def _tick(self): - """Update the elapsed time display.""" - self._elapsed_seconds += 1 - self._update_display() - - def _update_display(self): - """Update the time display label.""" - hours = self._elapsed_seconds // 3600 - minutes = (self._elapsed_seconds % 3600) // 60 - seconds = self._elapsed_seconds % 60 - self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}") - - @Slot() - def _stop_and_log(self): - """Stop the timer and emit signal to open time log.""" - if self._running: - self._running = False - self._timer.stop() - - self.timerStopped.emit(self._elapsed_seconds, self._task_text) - self.accept() - - -class PomodoroManager: - """Manages Pomodoro timers and integrates with time log.""" - - def __init__(self, db: DBManager, parent_window): - self._db = db - self._parent = parent_window - self._active_timer: Optional[PomodoroTimer] = None - - def start_timer_for_line(self, line_text: str, date_iso: str): - """Start a new timer for the given line of text.""" - # Stop any existing timer - if self._active_timer and self._active_timer.isVisible(): - self._active_timer.close() - - # Create new timer - self._active_timer = PomodoroTimer(line_text, self._parent) - self._active_timer.timerStopped.connect( - lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) - ) - self._active_timer.show() - - def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): - """Handle timer stop - open time log dialog with pre-filled data.""" - # Convert seconds to decimal hours, rounded up - hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour - - # Ensure minimum of 0.25 hours - if hours < 0.25: - hours = 0.25 - - # Open time log dialog - dlg = TimeLogDialog(self._db, date_iso, self._parent) - - # Pre-fill the hours - dlg.hours_spin.setValue(hours) - - # Pre-fill the note with task text - dlg.note.setText(task_text) - - # Show the dialog - dlg.exec() diff --git a/bouquin/reminders.py b/bouquin/reminders.py deleted file mode 100644 index 5306206..0000000 --- a/bouquin/reminders.py +++ /dev/null @@ -1,639 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import Optional - -from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QFormLayout, - QLineEdit, - QComboBox, - QTimeEdit, - QPushButton, - QFrame, - QWidget, - QToolButton, - QListWidget, - QListWidgetItem, - QStyle, - QSizePolicy, - QMessageBox, - QTableWidget, - QTableWidgetItem, - QAbstractItemView, - QHeaderView, -) - -from . import strings -from .db import DBManager - - -class ReminderType(Enum): - ONCE = strings._("once") - DAILY = strings._("daily") - WEEKDAYS = strings._("weekdays") # Mon-Fri - WEEKLY = strings._("weekly") # specific day of week - - -@dataclass -class Reminder: - id: Optional[int] - text: str - time_str: str # HH:MM - reminder_type: ReminderType - weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type) - active: bool = True - date_iso: Optional[str] = None # For ONCE type - - -class ReminderDialog(QDialog): - """Dialog for creating/editing reminders with recurrence support.""" - - def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None): - super().__init__(parent) - self._db = db - self._reminder = reminder - - self.setWindowTitle( - strings._("set_reminder") if not reminder else strings._("edit_reminder") - ) - self.setMinimumWidth(400) - - layout = QVBoxLayout(self) - form = QFormLayout() - - # Reminder text - self.text_edit = QLineEdit() - if reminder: - self.text_edit.setText(reminder.text) - form.addRow("&" + strings._("reminder") + ":", self.text_edit) - - # Time - self.time_edit = QTimeEdit() - self.time_edit.setDisplayFormat("HH:mm") - if reminder: - parts = reminder.time_str.split(":") - self.time_edit.setTime(QTime(int(parts[0]), int(parts[1]))) - else: - self.time_edit.setTime(QTime.currentTime()) - form.addRow("&" + strings._("time") + ":", self.time_edit) - - # Recurrence type - self.type_combo = QComboBox() - self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE) - self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY) - self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS) - self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY) - - if reminder: - for i in range(self.type_combo.count()): - if self.type_combo.itemData(i) == reminder.reminder_type: - self.type_combo.setCurrentIndex(i) - break - - self.type_combo.currentIndexChanged.connect(self._on_type_changed) - form.addRow("&" + strings._("repeat") + ":", self.type_combo) - - # Weekday selector (for weekly reminders) - self.weekday_combo = QComboBox() - days = [ - strings._("monday"), - strings._("tuesday"), - strings._("wednesday"), - strings._("thursday"), - strings._("friday"), - strings._("saturday"), - strings._("sunday"), - ] - for i, day in enumerate(days): - self.weekday_combo.addItem(day, i) - - if reminder and reminder.weekday is not None: - self.weekday_combo.setCurrentIndex(reminder.weekday) - else: - self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1) - - form.addRow("&" + strings._("day") + ":", self.weekday_combo) - - layout.addLayout(form) - - # Buttons - btn_layout = QHBoxLayout() - btn_layout.addStretch() - - save_btn = QPushButton("&" + strings._("save")) - save_btn.clicked.connect(self.accept) - save_btn.setDefault(True) - btn_layout.addWidget(save_btn) - - cancel_btn = QPushButton("&" + strings._("cancel")) - cancel_btn.clicked.connect(self.reject) - btn_layout.addWidget(cancel_btn) - - layout.addLayout(btn_layout) - - self._on_type_changed() - - def _on_type_changed(self): - """Show/hide weekday selector based on reminder type.""" - reminder_type = self.type_combo.currentData() - self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY) - - def get_reminder(self) -> Reminder: - """Get the configured reminder.""" - reminder_type = self.type_combo.currentData() - time_obj = self.time_edit.time() - time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}" - - weekday = None - if reminder_type == ReminderType.WEEKLY: - weekday = self.weekday_combo.currentData() - - date_iso = None - if reminder_type == ReminderType.ONCE: - # Right now this just means "today at the chosen time". - date_iso = QDate.currentDate().toString("yyyy-MM-dd") - - return Reminder( - id=self._reminder.id if self._reminder else None, - text=self.text_edit.text(), - time_str=time_str, - reminder_type=reminder_type, - weekday=weekday, - active=self._reminder.active if self._reminder else True, - date_iso=date_iso, - ) - - -class UpcomingRemindersWidget(QFrame): - """Collapsible widget showing upcoming reminders for today and next 7 days.""" - - reminderTriggered = Signal(str) # Emits reminder text - - def __init__(self, db: DBManager, parent: Optional[QWidget] = None): - super().__init__(parent) - self._db = db - - self.setFrameShape(QFrame.StyledPanel) - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - - # Header with toggle button - self.toggle_btn = QToolButton() - self.toggle_btn.setText("Upcoming Reminders") - self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - self.toggle_btn.setCheckable(True) - self.toggle_btn.setChecked(False) - self.toggle_btn.setArrowType(Qt.RightArrow) - self.toggle_btn.clicked.connect(self._on_toggle) - - self.add_btn = QToolButton() - self.add_btn.setText("⏰") - self.add_btn.setToolTip("Add Reminder") - self.add_btn.setAutoRaise(True) - self.add_btn.clicked.connect(self._add_reminder) - - self.manage_btn = QToolButton() - self.manage_btn.setIcon( - self.style().standardIcon(QStyle.SP_FileDialogDetailedView) - ) - self.manage_btn.setToolTip("Manage All Reminders") - self.manage_btn.setAutoRaise(True) - self.manage_btn.clicked.connect(self._manage_reminders) - - header = QHBoxLayout() - header.setContentsMargins(0, 0, 0, 0) - header.addWidget(self.toggle_btn) - header.addStretch() - header.addWidget(self.add_btn) - header.addWidget(self.manage_btn) - - # Body with reminder list - self.body = QWidget() - body_layout = QVBoxLayout(self.body) - body_layout.setContentsMargins(0, 4, 0, 0) - body_layout.setSpacing(2) - - self.reminder_list = QListWidget() - self.reminder_list.setMaximumHeight(200) - self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.reminder_list.itemDoubleClicked.connect(self._edit_reminder) - self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu) - self.reminder_list.customContextMenuRequested.connect( - self._show_reminder_context_menu - ) - body_layout.addWidget(self.reminder_list) - - self.body.setVisible(False) - - main = QVBoxLayout(self) - main.setContentsMargins(0, 0, 0, 0) - main.addLayout(header) - main.addWidget(self.body) - - # Timer to check and fire reminders - # Start by syncing to the next minute boundary - self._check_timer = QTimer(self) - self._check_timer.timeout.connect(self._check_reminders) - - # Calculate milliseconds until next minute (HH:MM:00) - now = QDateTime.currentDateTime() - current_second = now.time().second() - current_msec = now.time().msec() - - # Milliseconds until next minute - ms_until_next_minute = (60 - current_second) * 1000 - current_msec - - # Start with a single-shot to sync to the minute - self._sync_timer = QTimer(self) - self._sync_timer.setSingleShot(True) - self._sync_timer.timeout.connect(self._start_regular_timer) - self._sync_timer.start(ms_until_next_minute) - - # Also check immediately in case there are pending reminders - QTimer.singleShot(1000, self._check_reminders) - - def __del__(self): - """Cleanup timers when widget is destroyed.""" - try: - if hasattr(self, "_check_timer") and self._check_timer: - self._check_timer.stop() - if hasattr(self, "_sync_timer") and self._sync_timer: - self._sync_timer.stop() - except: - pass # Ignore any cleanup errors - - def _start_regular_timer(self): - """Start the regular check timer after initial sync.""" - # Now we're at a minute boundary, check and start regular timer - self._check_reminders() - self._check_timer.start(60000) # Check every minute - - def _on_toggle(self, checked: bool): - """Toggle visibility of reminder list.""" - self.body.setVisible(checked) - self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) - if checked: - self.refresh() - - def refresh(self): - """Reload and display upcoming reminders.""" - # Guard: Check if database connection is valid - if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: - return - - self.reminder_list.clear() - - reminders = self._db.get_all_reminders() - now = QDateTime.currentDateTime() - today = QDate.currentDate() - - # Get reminders for the next 7 days - upcoming = [] - for i in range(8): # Today + 7 days - check_date = today.addDays(i) - - for reminder in reminders: - if not reminder.active: - continue - - if self._should_fire_on_date(reminder, check_date): - # Parse time - hour, minute = map(int, reminder.time_str.split(":")) - dt = QDateTime(check_date, QTime(hour, minute)) - - # Skip past reminders - if dt < now: - continue - - upcoming.append((dt, reminder)) - - # Sort by datetime - upcoming.sort(key=lambda x: x[0]) - - # Display - for dt, reminder in upcoming[:20]: # Show max 20 - date_str = dt.date().toString("ddd MMM d") - time_str = dt.time().toString("HH:mm") - - item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}") - item.setData(Qt.UserRole, reminder) - self.reminder_list.addItem(item) - - if not upcoming: - item = QListWidgetItem("No upcoming reminders") - item.setFlags(Qt.NoItemFlags) - self.reminder_list.addItem(item) - - def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool: - """Check if a reminder should fire on a given date.""" - if reminder.reminder_type == ReminderType.ONCE: - if reminder.date_iso: - return date.toString("yyyy-MM-dd") == reminder.date_iso - return False - elif reminder.reminder_type == ReminderType.DAILY: - return True - elif reminder.reminder_type == ReminderType.WEEKDAYS: - # Monday=1, Sunday=7 - return 1 <= date.dayOfWeek() <= 5 - elif reminder.reminder_type == ReminderType.WEEKLY: - # Qt: Monday=1, reminder: Monday=0 - return date.dayOfWeek() - 1 == reminder.weekday - return False - - def _check_reminders(self): - """Check if any reminders should fire now.""" - # Guard: Check if database connection is valid - if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: - return - - now = QDateTime.currentDateTime() - today = QDate.currentDate() - - # Round current time to the minute (set seconds to 0) - current_minute = QDateTime( - today, QTime(now.time().hour(), now.time().minute(), 0) - ) - - reminders = self._db.get_all_reminders() - for reminder in reminders: - if not reminder.active: - continue - - if not self._should_fire_on_date(reminder, today): - continue - - # Parse time - hour, minute = map(int, reminder.time_str.split(":")) - target = QDateTime(today, QTime(hour, minute, 0)) - - # Fire if we've passed the target minute (within last 2 minutes to catch missed ones) - seconds_diff = current_minute.secsTo(target) - if -120 <= seconds_diff <= 0: - # Check if we haven't already fired this one - if not hasattr(self, "_fired_reminders"): - self._fired_reminders = {} - - reminder_key = (reminder.id, target.toString()) - - # Only fire once per reminder per target time - if reminder_key not in self._fired_reminders: - self._fired_reminders[reminder_key] = current_minute - self.reminderTriggered.emit(reminder.text) - - # For ONCE reminders, deactivate after firing - if reminder.reminder_type == ReminderType.ONCE: - self._db.update_reminder_active(reminder.id, False) - self.refresh() # Refresh the list to show deactivated reminder - - @Slot() - def _add_reminder(self): - """Open dialog to add a new reminder.""" - dlg = ReminderDialog(self._db, self) - if dlg.exec() == QDialog.Accepted: - reminder = dlg.get_reminder() - self._db.save_reminder(reminder) - self.refresh() - - @Slot(QListWidgetItem) - def _edit_reminder(self, item: QListWidgetItem): - """Edit an existing reminder.""" - reminder = item.data(Qt.UserRole) - if not reminder: - return - - dlg = ReminderDialog(self._db, self, reminder) - if dlg.exec() == QDialog.Accepted: - updated = dlg.get_reminder() - self._db.save_reminder(updated) - self.refresh() - - @Slot() - def _show_reminder_context_menu(self, pos): - """Show context menu for reminder list item(s).""" - selected_items = self.reminder_list.selectedItems() - if not selected_items: - return - - from PySide6.QtWidgets import QMenu - from PySide6.QtGui import QAction - - menu = QMenu(self) - - # Only show Edit if single item selected - if len(selected_items) == 1: - reminder = selected_items[0].data(Qt.UserRole) - if reminder: - edit_action = QAction("Edit", self) - edit_action.triggered.connect( - lambda: self._edit_reminder(selected_items[0]) - ) - menu.addAction(edit_action) - - # Delete option for any selection - if len(selected_items) == 1: - delete_text = "Delete" - else: - delete_text = f"Delete {len(selected_items)} Reminders" - - delete_action = QAction(delete_text, self) - delete_action.triggered.connect(lambda: self._delete_selected_reminders()) - menu.addAction(delete_action) - - menu.exec(self.reminder_list.mapToGlobal(pos)) - - def _delete_selected_reminders(self): - """Delete all selected reminders (handling duplicates).""" - selected_items = self.reminder_list.selectedItems() - if not selected_items: - return - - # Collect unique reminder IDs - unique_reminders = {} - for item in selected_items: - reminder = item.data(Qt.UserRole) - if reminder and reminder.id not in unique_reminders: - unique_reminders[reminder.id] = reminder - - if not unique_reminders: - return - - # Confirmation message - if len(unique_reminders) == 1: - reminder = list(unique_reminders.values())[0] - msg = f"Delete reminder '{reminder.text}'?" - if reminder.reminder_type != ReminderType.ONCE: - msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences." - else: - msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences." - - reply = QMessageBox.question( - self, - "Delete Reminders", - msg, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - - if reply == QMessageBox.Yes: - for reminder_id in unique_reminders: - self._db.delete_reminder(reminder_id) - self.refresh() - - def _delete_reminder(self, reminder): - """Delete a single reminder after confirmation.""" - msg = f"Delete reminder '{reminder.text}'?" - if reminder.reminder_type != ReminderType.ONCE: - msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences." - - reply = QMessageBox.question( - self, - "Delete Reminder", - msg, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - - if reply == QMessageBox.Yes: - self._db.delete_reminder(reminder.id) - self.refresh() - - @Slot() - def _manage_reminders(self): - """Open dialog to manage all reminders.""" - dlg = ManageRemindersDialog(self._db, self) - dlg.exec() - self.refresh() - - -class ManageRemindersDialog(QDialog): - """Dialog for managing all reminders.""" - - def __init__(self, db: DBManager, parent: Optional[QWidget] = None): - super().__init__(parent) - self._db = db - - self.setWindowTitle("Manage Reminders") - self.setMinimumSize(700, 500) - - layout = QVBoxLayout(self) - - # Reminder list table - self.table = QTableWidget() - self.table.setColumnCount(5) - self.table.setHorizontalHeaderLabels( - ["Text", "Time", "Type", "Active", "Actions"] - ) - self.table.horizontalHeader().setStretchLastSection(False) - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - layout.addWidget(self.table) - - # Buttons - btn_layout = QHBoxLayout() - - add_btn = QPushButton("Add Reminder") - add_btn.clicked.connect(self._add_reminder) - btn_layout.addWidget(add_btn) - - btn_layout.addStretch() - - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.accept) - btn_layout.addWidget(close_btn) - - layout.addLayout(btn_layout) - - self._load_reminders() - - def _load_reminders(self): - """Load all reminders into the table.""" - - # Guard: Check if database connection is valid - if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: - return - - reminders = self._db.get_all_reminders() - self.table.setRowCount(len(reminders)) - - for row, reminder in enumerate(reminders): - # Text - text_item = QTableWidgetItem(reminder.text) - text_item.setData(Qt.UserRole, reminder) - self.table.setItem(row, 0, text_item) - - # Time - time_item = QTableWidgetItem(reminder.time_str) - self.table.setItem(row, 1, time_item) - - # Type - type_str = { - ReminderType.ONCE: "Once", - ReminderType.DAILY: "Daily", - ReminderType.WEEKDAYS: "Weekdays", - ReminderType.WEEKLY: "Weekly", - }.get(reminder.reminder_type, "Unknown") - - if ( - reminder.reminder_type == ReminderType.WEEKLY - and reminder.weekday is not None - ): - days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - type_str += f" ({days[reminder.weekday]})" - - type_item = QTableWidgetItem(type_str) - self.table.setItem(row, 2, type_item) - - # Active - active_item = QTableWidgetItem("✓" if reminder.active else "✗") - self.table.setItem(row, 3, active_item) - - # Actions - actions_widget = QWidget() - actions_layout = QHBoxLayout(actions_widget) - actions_layout.setContentsMargins(2, 2, 2, 2) - - edit_btn = QPushButton("Edit") - edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r)) - actions_layout.addWidget(edit_btn) - - delete_btn = QPushButton("Delete") - delete_btn.clicked.connect( - lambda checked, r=reminder: self._delete_reminder(r) - ) - actions_layout.addWidget(delete_btn) - - self.table.setCellWidget(row, 4, actions_widget) - - def _add_reminder(self): - """Add a new reminder.""" - dlg = ReminderDialog(self._db, self) - if dlg.exec() == QDialog.Accepted: - reminder = dlg.get_reminder() - self._db.save_reminder(reminder) - self._load_reminders() - - def _edit_reminder(self, reminder): - """Edit an existing reminder.""" - dlg = ReminderDialog(self._db, self, reminder) - if dlg.exec() == QDialog.Accepted: - updated = dlg.get_reminder() - self._db.save_reminder(updated) - self._load_reminders() - - def _delete_reminder(self, reminder): - """Delete a reminder.""" - reply = QMessageBox.question( - self, - "Delete Reminder", - f"Delete reminder '{reminder.text}'?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - - if reply == QMessageBox.Yes: - self._db.delete_reminder(reminder.id) - self._load_reminders() diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 6b4e05d..27feeaf 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -2,7 +2,6 @@ from __future__ import annotations import datetime -from PySide6.QtGui import QFontMetrics from PySide6.QtWidgets import ( QDialog, QVBoxLayout, @@ -11,36 +10,25 @@ from PySide6.QtWidgets import ( QDialogButtonBox, ) -from . import strings - class SaveDialog(QDialog): def __init__( self, parent=None, + title: str = "Enter a name for this version", + message: str = "Enter a name for this version?", ): """ Used for explicitly saving a new version of a page. """ super().__init__(parent) - - self.setWindowTitle(strings._("enter_a_name_for_this_version")) - + self.setWindowTitle(title) v = QVBoxLayout(self) - v.addWidget(QLabel(strings._("enter_a_name_for_this_version"))) - + v.addWidget(QLabel(message)) self.note = QLineEdit() now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - text = strings._("new_version_i_saved_at") + f" {now}" - self.note.setText(text) + self.note.setText(f"New version I saved at {now}") v.addWidget(self.note) - - # make dialog wide enough for the line edit text - fm = QFontMetrics(self.note.font()) - text_width = fm.horizontalAdvance(text) + 20 - self.note.setMinimumWidth(text_width) - self.adjustSize() - bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) diff --git a/bouquin/search.py b/bouquin/search.py index 95a94de..a1bb15c 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -16,8 +16,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from . import strings - Row = Tuple[str, str] @@ -32,7 +30,7 @@ class Search(QWidget): self._db = db self.search = QLineEdit() - self.search.setPlaceholderText(strings._("search_for_notes_here")) + self.search.setPlaceholderText("Search for notes here") self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.search.textChanged.connect(self._search) @@ -68,7 +66,10 @@ class Search(QWidget): self.resultDatesChanged.emit([]) # clear highlights return - rows: Iterable[Row] = self._db.search_entries(q) + try: + rows: Iterable[Row] = self._db.search_entries(q) + except Exception: + rows = [] self._populate_results(q, rows) @@ -85,7 +86,10 @@ class Search(QWidget): for date_str, content in rows: # Build an HTML fragment around the match and whether to show ellipses - frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90) + frag_html, left_ell, right_ell = self._make_html_snippet( + content, query, radius=30, maxlen=90 + ) + # ---- Per-item widget: date on top, preview row below (with ellipses) ---- container = QWidget() outer = QVBoxLayout(container) @@ -107,6 +111,11 @@ class Search(QWidget): h.setContentsMargins(0, 0, 0, 0) h.setSpacing(4) + if left_ell: + left = QLabel("…") + left.setStyleSheet("color:#888;") + h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop) + preview = QLabel() preview.setTextFormat(Qt.TextFormat.RichText) preview.setWordWrap(True) @@ -118,6 +127,11 @@ class Search(QWidget): ) h.addWidget(preview, 1) + if right_ell: + right = QLabel("…") + right.setStyleSheet("color:#888;") + h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom) + outer.addWidget(row) line = QFrame() @@ -134,7 +148,9 @@ class Search(QWidget): self.results.setItemWidget(item, container) # --- Snippet/highlight helpers ----------------------------------------- - def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180): + def _make_html_snippet( + self, markdown_src: str, query: str, *, radius=60, maxlen=180 + ): # For markdown, we can work directly with the text # Strip markdown formatting for display plain = self._strip_markdown(markdown_src) @@ -179,7 +195,7 @@ class Search(QWidget): lambda m: f"{m.group(0)}", snippet_html ) - return snippet_html + return snippet_html, start > 0, end < L def _strip_markdown(self, markdown: str) -> str: """Strip markdown formatting for plain text display.""" diff --git a/bouquin/settings.py b/bouquin/settings.py index 011d39a..2201b09 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -9,66 +9,31 @@ APP_ORG = "Bouquin" APP_NAME = "Bouquin" +def default_db_path() -> Path: + base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) + return base / "notebook.db" + + def get_settings() -> QSettings: return QSettings(APP_ORG, APP_NAME) -def _default_db_location() -> Path: - """Where we put the notebook if nothing has been configured yet.""" - base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) - base.mkdir(parents=True, exist_ok=True) - return base / "notebook.db" - - def load_db_config() -> DBConfig: s = get_settings() - - # --- DB Path ------------------------------------------------------- - # Prefer the new key; fall back to the legacy one. - path_str = s.value("db/default_db", "", type=str) - if not path_str: - legacy = s.value("db/path", "", type=str) - if legacy: - path_str = legacy - # migrate and clean up the old key - s.setValue("db/default_db", legacy) - s.remove("db/path") - path = Path(path_str) if path_str else _default_db_location() - - # --- Other settings ------------------------------------------------ + path = Path(s.value("db/path", str(default_db_path()))) key = s.value("db/key", "") - idle = s.value("ui/idle_minutes", 15, type=int) theme = s.value("ui/theme", "system", type=str) move_todos = s.value("ui/move_todos", False, type=bool) - tags = s.value("ui/tags", True, type=bool) - time_log = s.value("ui/time_log", True, type=bool) - reminders = s.value("ui/reminders", True, type=bool) - locale = s.value("ui/locale", "en", type=str) - font_size = s.value("ui/font_size", 11, type=int) return DBConfig( - path=path, - key=key, - idle_minutes=idle, - theme=theme, - move_todos=move_todos, - tags=tags, - time_log=time_log, - reminders=reminders, - locale=locale, - font_size=font_size, + path=path, key=key, idle_minutes=idle, theme=theme, move_todos=move_todos ) def save_db_config(cfg: DBConfig) -> None: s = get_settings() - s.setValue("db/default_db", str(cfg.path)) + s.setValue("db/path", str(cfg.path)) s.setValue("db/key", str(cfg.key)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/move_todos", str(cfg.move_todos)) - s.setValue("ui/tags", str(cfg.tags)) - s.setValue("ui/time_log", str(cfg.time_log)) - s.setValue("ui/reminders", str(cfg.reminders)) - s.setValue("ui/locale", str(cfg.locale)) - s.setValue("ui/font_size", str(cfg.font_size)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 90f301d..5b11381 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -4,21 +4,22 @@ from pathlib import Path from PySide6.QtWidgets import ( QCheckBox, - QComboBox, QDialog, + QFormLayout, QFrame, QGroupBox, QLabel, QHBoxLayout, QVBoxLayout, + QWidget, + QLineEdit, QPushButton, + QFileDialog, QDialogButtonBox, QRadioButton, QSizePolicy, QSpinBox, QMessageBox, - QWidget, - QTabWidget, ) from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QPalette @@ -29,64 +30,32 @@ from .settings import load_db_config, save_db_config from .theme import Theme from .key_prompt import KeyPrompt -from . import strings - class SettingsDialog(QDialog): def __init__(self, cfg: DBConfig, db: DBManager, parent=None): super().__init__(parent) - self.setWindowTitle(strings._("settings")) + self.setWindowTitle("Settings") self._cfg = DBConfig(path=cfg.path, key="") self._db = db self.key = "" - self.current_settings = load_db_config() - - self.setMinimumWidth(480) + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + self.setMinimumWidth(560) self.setSizeGripEnabled(True) - # --- Tabs ---------------------------------------------------------- - tabs = QTabWidget() - tabs.setTabPosition(QTabWidget.North) - tabs.setDocumentMode(True) - tabs.setMovable(False) + current_settings = load_db_config() - tabs.addTab(self._create_appearance_page(cfg), strings._("appearance")) - tabs.addTab(self._create_features_page(), strings._("features")) - tabs.addTab(self._create_security_page(cfg), strings._("security")) - tabs.addTab(self._create_database_page(), strings._("database")) - - # --- Buttons ------------------------------------------------------- - bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) - bb.accepted.connect(self._save) - bb.rejected.connect(self.reject) - - # Root layout - root = QVBoxLayout(self) - root.setContentsMargins(12, 12, 12, 12) - root.setSpacing(8) - root.addWidget(tabs) - root.addWidget(bb, 0, Qt.AlignRight) - - # ------------------------------------------------------------------ # - # Pages - # ------------------------------------------------------------------ # - - def _create_appearance_page(self, cfg: DBConfig) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - - # --- Theme group -------------------------------------------------- - theme_group = QGroupBox(strings._("theme")) + # Add theme selection + theme_group = QGroupBox("Theme") theme_layout = QVBoxLayout(theme_group) - self.theme_system = QRadioButton(strings._("system")) - self.theme_light = QRadioButton(strings._("light")) - self.theme_dark = QRadioButton(strings._("dark")) + self.theme_system = QRadioButton("System") + self.theme_light = QRadioButton("Light") + self.theme_dark = QRadioButton("Dark") - current_theme = self.current_settings.theme + # Load current theme from settings + current_theme = current_settings.theme if current_theme == Theme.DARK.value: self.theme_dark.setChecked(True) elif current_theme == Theme.LIGHT.value: @@ -98,119 +67,63 @@ class SettingsDialog(QDialog): theme_layout.addWidget(self.theme_light) theme_layout.addWidget(self.theme_dark) - # font size row - font_row = QHBoxLayout() - self.font_heading = QLabel(strings._("font_size")) - self.font_size = QSpinBox() - self.font_size.setRange(1, 24) - self.font_size.setSingleStep(1) - self.font_size.setAccelerated(True) - self.font_size.setValue(getattr(cfg, "font_size", 11)) - font_row.addWidget(self.font_heading) - font_row.addWidget(self.font_size) - font_row.addStretch() - theme_layout.addLayout(font_row) + form.addRow(theme_group) - # explanation - self.font_size_label = QLabel(strings._("font_size_explanation")) - self.font_size_label.setWordWrap(True) - self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - pal = self.font_size_label.palette() - self.font_size_label.setForegroundRole(QPalette.PlaceholderText) - self.font_size_label.setPalette(pal) - - font_exp_row = QHBoxLayout() - font_exp_row.setContentsMargins(24, 0, 0, 0) - font_exp_row.addWidget(self.font_size_label) - theme_layout.addLayout(font_exp_row) - - layout.addWidget(theme_group) - - # --- Locale group ------------------------------------------------- - locale_group = QGroupBox(strings._("locale")) - locale_layout = QVBoxLayout(locale_group) - - self.locale_combobox = QComboBox() - self.locale_combobox.addItems(strings._AVAILABLE) - self.locale_combobox.setCurrentText(self.current_settings.locale) - locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft) - - self.locale_label = QLabel(strings._("locale_restart")) - self.locale_label.setWordWrap(True) - self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - lpal = self.locale_label.palette() - self.locale_label.setForegroundRole(QPalette.PlaceholderText) - self.locale_label.setPalette(lpal) - loc_row = QHBoxLayout() - loc_row.setContentsMargins(24, 0, 0, 0) - loc_row.addWidget(self.locale_label) - locale_layout.addLayout(loc_row) - - layout.addWidget(locale_group) - layout.addStretch() - return page - - def _create_features_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - - features_group = QGroupBox(strings._("features")) - features_layout = QVBoxLayout(features_group) + # Add Behaviour + behaviour_group = QGroupBox("Behaviour") + behaviour_layout = QVBoxLayout(behaviour_group) self.move_todos = QCheckBox( - strings._("move_unchecked_todos_to_today_on_startup") + "Move yesterday's unchecked TODOs to today on startup" ) - self.move_todos.setChecked(self.current_settings.move_todos) + self.move_todos.setChecked(current_settings.move_todos) self.move_todos.setCursor(Qt.PointingHandCursor) - features_layout.addWidget(self.move_todos) - self.tags = QCheckBox(strings._("enable_tags_feature")) - self.tags.setChecked(self.current_settings.tags) - self.tags.setCursor(Qt.PointingHandCursor) - features_layout.addWidget(self.tags) + behaviour_layout.addWidget(self.move_todos) + form.addRow(behaviour_group) - self.time_log = QCheckBox(strings._("enable_time_log_feature")) - self.time_log.setChecked(self.current_settings.time_log) - self.time_log.setCursor(Qt.PointingHandCursor) - features_layout.addWidget(self.time_log) + self.path_edit = QLineEdit(str(self._cfg.path)) + self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + browse_btn = QPushButton("Browse…") + browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + browse_btn.clicked.connect(self._browse) + path_row = QWidget() + h = QHBoxLayout(path_row) + h.setContentsMargins(0, 0, 0, 0) + h.addWidget(self.path_edit, 1) + h.addWidget(browse_btn, 0) + h.setStretch(0, 1) + h.setStretch(1, 0) + form.addRow("Database path", path_row) - self.reminders = QCheckBox(strings._("enable_reminders_feature")) - self.reminders.setChecked(self.current_settings.reminders) - self.reminders.setCursor(Qt.PointingHandCursor) - features_layout.addWidget(self.reminders) - - layout.addWidget(features_group) - layout.addStretch() - return page - - def _create_security_page(self, cfg: DBConfig) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - - # --- Encryption group --------------------------------------------- - enc_group = QGroupBox(strings._("encryption")) + # Encryption settings + enc_group = QGroupBox("Encryption") enc = QVBoxLayout(enc_group) + enc.setContentsMargins(12, 8, 12, 12) + enc.setSpacing(6) - self.save_key_btn = QCheckBox(strings._("remember_key")) - self.key = self.current_settings.key or "" + # Checkbox to remember key + self.save_key_btn = QCheckBox("Remember key") + self.key = current_settings.key or "" self.save_key_btn.setChecked(bool(self.key)) self.save_key_btn.setCursor(Qt.PointingHandCursor) self.save_key_btn.toggled.connect(self._save_key_btn_clicked) enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft) - self.save_key_label = QLabel(strings._("save_key_warning")) + # Explanation for remembering key + self.save_key_label = QLabel( + "If you don't want to be prompted for your encryption key, check this to remember it. " + "WARNING: the key is saved to disk and could be recoverable if your disk is compromised." + ) self.save_key_label.setWordWrap(True) self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + # make it look secondary pal = self.save_key_label.palette() - self.save_key_label.setForegroundRole(QPalette.PlaceholderText) + pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid)) self.save_key_label.setPalette(pal) exp_row = QHBoxLayout() - exp_row.setContentsMargins(24, 0, 0, 0) + exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox exp_row.addWidget(self.save_key_label) enc.addLayout(exp_row) @@ -219,77 +132,102 @@ class SettingsDialog(QDialog): line.setFrameShadow(QFrame.Sunken) enc.addWidget(line) - self.rekey_btn = QPushButton(strings._("change_encryption_key")) + # Change key button + self.rekey_btn = QPushButton("Change encryption key") self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.rekey_btn.clicked.connect(self._change_key) + enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft) - layout.addWidget(enc_group) + form.addRow(enc_group) - # --- Idle lock group ---------------------------------------------- - priv_group = QGroupBox(strings._("lock_screen_when_idle")) + # Privacy settings + priv_group = QGroupBox("Lock screen when idle") priv = QVBoxLayout(priv_group) + priv.setContentsMargins(12, 8, 12, 12) + priv.setSpacing(6) self.idle_spin = QSpinBox() self.idle_spin.setRange(0, 240) self.idle_spin.setSingleStep(1) self.idle_spin.setAccelerated(True) self.idle_spin.setSuffix(" min") - self.idle_spin.setSpecialValueText(strings._("never")) + self.idle_spin.setSpecialValueText("Never") self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15)) priv.addWidget(self.idle_spin, 0, Qt.AlignLeft) - - self.idle_spin_label = QLabel(strings._("autolock_explanation")) + # Explanation for idle option (autolock) + self.idle_spin_label = QLabel( + "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. " + "Set to 0 (never) to never lock." + ) self.idle_spin_label.setWordWrap(True) self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + # make it look secondary spal = self.idle_spin_label.palette() - self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText) + spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid)) self.idle_spin_label.setPalette(spal) spin_row = QHBoxLayout() - spin_row.setContentsMargins(24, 0, 0, 0) + spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox spin_row.addWidget(self.idle_spin_label) priv.addLayout(spin_row) - layout.addWidget(priv_group) - layout.addStretch() - return page + form.addRow(priv_group) - def _create_database_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - - maint_group = QGroupBox(strings._("database_maintenance")) + # Maintenance settings + maint_group = QGroupBox("Database maintenance") maint = QVBoxLayout(maint_group) + maint.setContentsMargins(12, 8, 12, 12) + maint.setSpacing(6) - self.compact_btn = QPushButton(strings._("database_compact")) + self.compact_btn = QPushButton("Compact database") self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.compact_btn.clicked.connect(self._compact_btn_clicked) + maint.addWidget(self.compact_btn, 0, Qt.AlignLeft) - self.compact_label = QLabel(strings._("database_compact_explanation")) + # Explanation for compating button + self.compact_label = QLabel( + "Compacting runs VACUUM on the database. This can help reduce its size." + ) self.compact_label.setWordWrap(True) self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + # make it look secondary cpal = self.compact_label.palette() - self.compact_label.setForegroundRole(QPalette.PlaceholderText) + cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid)) self.compact_label.setPalette(cpal) maint_row = QHBoxLayout() - maint_row.setContentsMargins(24, 0, 0, 0) + maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button maint_row.addWidget(self.compact_label) maint.addLayout(maint_row) - layout.addWidget(maint_group) - layout.addStretch() - return page + form.addRow(maint_group) - # ------------------------------------------------------------------ # - # Save settings - # ------------------------------------------------------------------ # + # Buttons + bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + bb.accepted.connect(self._save) + bb.rejected.connect(self.reject) + + # Root layout (adjust margins/spacing a bit) + v = QVBoxLayout(self) + v.setContentsMargins(12, 12, 12, 12) + v.setSpacing(10) + v.addLayout(form) + v.addWidget(bb, 0, Qt.AlignRight) + + def _browse(self): + p, _ = QFileDialog.getSaveFileName( + self, + "Choose database file", + self.path_edit.text(), + "DB Files (*.db);;All Files (*)", + ) + if p: + self.path_edit.setText(p) def _save(self): + # Save the selected theme into QSettings if self.theme_dark.isChecked(): selected_theme = Theme.DARK elif self.theme_light.isChecked(): @@ -300,16 +238,11 @@ class SettingsDialog(QDialog): key_to_save = self.key if self.save_key_btn.isChecked() else "" self._cfg = DBConfig( - path=Path(self.current_settings.path), + path=Path(self.path_edit.text()), key=key_to_save, idle_minutes=self.idle_spin.value(), theme=selected_theme.value, move_todos=self.move_todos.isChecked(), - tags=self.tags.isChecked(), - time_log=self.time_log.isChecked(), - reminders=self.reminders.isChecked(), - locale=self.locale_combobox.currentText(), - font_size=self.font_size.value(), ) save_db_config(self._cfg) @@ -317,39 +250,27 @@ class SettingsDialog(QDialog): self.accept() def _change_key(self): - p1 = KeyPrompt( - self, - title=strings._("change_encryption_key"), - message=strings._("enter_a_new_encryption_key"), - ) + p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key") if p1.exec() != QDialog.Accepted: return new_key = p1.key() - p2 = KeyPrompt( - self, - title=strings._("change_encryption_key"), - message=strings._("reenter_the_new_key"), - ) + p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key") if p2.exec() != QDialog.Accepted: return if new_key != p2.key(): - QMessageBox.warning( - self, strings._("key_mismatch"), strings._("key_mismatch_explanation") - ) + QMessageBox.warning(self, "Key mismatch", "The two entries did not match.") return if not new_key: - QMessageBox.warning( - self, strings._("empty_key"), strings._("empty_key_explanation") - ) + QMessageBox.warning(self, "Empty key", "Key cannot be empty.") return try: self.key = new_key self._db.rekey(new_key) QMessageBox.information( - self, strings._("key_changed"), strings._("key_changed_explanation") + self, "Key changed", "The notebook was re-encrypted with the new key!" ) except Exception as e: - QMessageBox.critical(self, strings._("error"), str(e)) + QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") @Slot(bool) def _save_key_btn_clicked(self, checked: bool): @@ -357,9 +278,7 @@ class SettingsDialog(QDialog): if checked: if not self.key: p1 = KeyPrompt( - self, - title=strings._("unlock_encrypted_notebook_explanation"), - message=strings._("unlock_encrypted_notebook_explanation"), + self, title="Enter your key", message="Enter the encryption key" ) if p1.exec() != QDialog.Accepted: self.save_key_btn.blockSignals(True) @@ -373,10 +292,10 @@ class SettingsDialog(QDialog): try: self._db.compact() QMessageBox.information( - self, strings._("success"), strings._("database_compacted_successfully") + self, "Compact complete", "Database compacted successfully!" ) except Exception as e: - QMessageBox.critical(self, strings._("error"), str(e)) + QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}") @property def config(self) -> DBConfig: diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py deleted file mode 100644 index 37b5394..0000000 --- a/bouquin/statistics_dialog.py +++ /dev/null @@ -1,356 +0,0 @@ -from __future__ import annotations - -import datetime as _dt -from typing import Dict - -from PySide6.QtCore import Qt, QSize, Signal -from PySide6.QtGui import QColor, QPainter, QPen, QBrush -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QFormLayout, - QLabel, - QGroupBox, - QHBoxLayout, - QComboBox, - QScrollArea, - QWidget, - QSizePolicy, -) - -from . import strings -from .db import DBManager - - -# ---------- Activity heatmap ---------- - - -class DateHeatmap(QWidget): - """ - Small calendar heatmap for activity by date. - - Data is a mapping: datetime.date -> integer value. - """ - - date_clicked = Signal(_dt.date) - - def __init__(self, parent=None): - super().__init__(parent) - self._data: Dict[_dt.date, int] = {} - self._start: _dt.date | None = None - self._end: _dt.date | None = None - self._max_value: int = 0 - - self._cell = 12 - self._gap = 3 - self._margin_left = 30 - self._margin_top = 10 - self._margin_bottom = 24 - self._margin_right = 10 - - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - - def set_data(self, data: Dict[_dt.date, int]) -> None: - """Replace dataset and recompute layout.""" - self._data = {k: int(v) for k, v in (data or {}).items() if v is not None} - if not self._data: - self._start = self._end = None - self._max_value = 0 - else: - earliest = min(self._data.keys()) - latest = max(self._data.keys()) - self._start = earliest - _dt.timedelta(days=earliest.weekday()) - self._end = latest - self._max_value = max(self._data.values()) if self._data else 0 - - self.updateGeometry() - self.update() - - # QWidget overrides --------------------------------------------------- - - def sizeHint(self) -> QSize: - if not self._start or not self._end: - height = ( - self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap) - ) - # some default width - width = ( - self._margin_left + self._margin_right + 20 * (self._cell + self._gap) - ) - return QSize(width, height) - - day_count = (self._end - self._start).days + 1 - weeks = (day_count + 6) // 7 # ceil - - width = ( - self._margin_left - + self._margin_right - + weeks * (self._cell + self._gap) - + self._gap - ) - height = ( - self._margin_top - + self._margin_bottom - + 7 * (self._cell + self._gap) - + self._gap - ) - return QSize(width, height) - - def minimumSizeHint(self) -> QSize: - sz = self.sizeHint() - return QSize(min(380, sz.width()), sz.height()) - - def paintEvent(self, event): - super().paintEvent(event) - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing, True) - - if not self._start or not self._end: - return - - palette = self.palette() - bg_no_data = palette.base().color() - active = palette.highlight().color() - - painter.setPen(QPen(Qt.NoPen)) - - day_count = (self._end - self._start).days + 1 - weeks = (day_count + 6) // 7 - - for week in range(weeks): - for dow in range(7): - idx = week * 7 + dow - date = self._start + _dt.timedelta(days=idx) - if date > self._end: - value = 0 - else: - value = self._data.get(date, 0) - - x = self._margin_left + week * (self._cell + self._gap) - y = self._margin_top + dow * (self._cell + self._gap) - - if value <= 0 or self._max_value <= 0: - color = bg_no_data - else: - ratio = max(0.1, min(1.0, value / float(self._max_value))) - color = QColor(active) - # Lighter for low values, darker for high values - lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original - color = color.lighter(lighten) - - painter.fillRect( - x, - y, - self._cell, - self._cell, - QBrush(color), - ) - - painter.setPen(palette.text().color()) - fm = painter.fontMetrics() - - # --- weekday labels on left ------------------------------------- - # Python's weekday(): Monday=0 ... Sunday=6, same as your rows. - weekday_labels = ["M", "T", "W", "T", "F", "S", "S"] - - for dow in range(7): - label = weekday_labels[dow] - text_width = fm.horizontalAdvance(label) - - # Center text vertically in the cell - y_center = ( - self._margin_top + dow * (self._cell + self._gap) + self._cell / 2 - ) - baseline_y = int(y_center + fm.ascent() / 2 - fm.descent() / 2) - - # Right-align text just to the left of the first column - x = self._margin_left - self._gap - 2 - text_width - - painter.drawText(x, baseline_y, label) - - prev_month = None - for week in range(weeks): - date = self._start + _dt.timedelta(days=week * 7) - if date > self._end: # pragma: no cover - break - - if prev_month == date.month: - continue - prev_month = date.month - - label = date.strftime("%b") - - x_center = ( - self._margin_left + week * (self._cell + self._gap) + self._cell / 2 - ) - y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent() - - text_width = fm.horizontalAdvance(label) - painter.drawText( - int(x_center - text_width / 2), - int(y), - label, - ) - - painter.end() - - def mousePressEvent(self, event): - if event.button() != Qt.LeftButton: - return super().mousePressEvent(event) - - # No data = nothing to click - if not self._start or not self._end: - return - - # Qt6: position(), older: pos() - pos = event.position() if hasattr(event, "position") else event.pos() - x = pos.x() - y = pos.y() - - # Outside grid area (left of weekday labels or above rows) - if x < self._margin_left or y < self._margin_top: - return - - cell_span = self._cell + self._gap - col = int((x - self._margin_left) // cell_span) # week index - row = int((y - self._margin_top) // cell_span) # dow (0..6) - - # Only 7 rows (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 deleted file mode 100644 index eff0e18..0000000 --- a/bouquin/strings.py +++ /dev/null @@ -1,39 +0,0 @@ -from importlib.resources import files -import json - -# Get list of locales -root = files("bouquin") / "locales" -_AVAILABLE = tuple( - entry.stem - for entry in root.iterdir() - if entry.is_file() and entry.suffix == ".json" -) - -_DEFAULT = "en" - -strings = {} -translations = {} - - -def load_strings(current_locale: str) -> None: - global strings, translations - translations = {} - - # read in the locales json - for loc in _AVAILABLE: - data = (root / f"{loc}.json").read_text(encoding="utf-8") - translations[loc] = json.loads(data) - - if current_locale not in translations: - current_locale = _DEFAULT - - base = translations[_DEFAULT] - cur = translations.get(current_locale, {}) - strings = {k: (cur.get(k) or base[k]) for k in base} - - -def translated(k: str) -> str: - return strings.get(k, k) - - -_ = translated diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py deleted file mode 100644 index a5d12d0..0000000 --- a/bouquin/tag_browser.py +++ /dev/null @@ -1,253 +0,0 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QTreeWidget, - QTreeWidgetItem, - QPushButton, - QLabel, - QColorDialog, - QMessageBox, - QInputDialog, -) - -from .db import DBManager -from . import strings -from sqlcipher3.dbapi2 import IntegrityError - - -class TagBrowserDialog(QDialog): - openDateRequested = Signal(str) - tagsModified = Signal() - - def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None): - super().__init__(parent) - self._db = db - self.setWindowTitle( - strings._("tag_browser_title") + " / " + strings._("manage_tags") - ) - self.resize(600, 500) - - layout = QVBoxLayout(self) - - # Instructions - instructions = QLabel(strings._("tag_browser_instructions")) - instructions.setWordWrap(True) - layout.addWidget(instructions) - - self.tree = QTreeWidget() - self.tree.setHeaderLabels( - [strings._("tag"), strings._("color_hex"), strings._("date")] - ) - self.tree.setColumnWidth(0, 200) - self.tree.setColumnWidth(1, 100) - self.tree.itemActivated.connect(self._on_item_activated) - self.tree.itemClicked.connect(self._on_item_clicked) - self.tree.setSortingEnabled(True) - self.tree.sortByColumn(0, Qt.AscendingOrder) - layout.addWidget(self.tree) - - # Tag management buttons - btn_row = QHBoxLayout() - - self.add_tag_btn = QPushButton("&" + strings._("add_a_tag")) - self.add_tag_btn.clicked.connect(self._add_a_tag) - btn_row.addWidget(self.add_tag_btn) - - self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name")) - self.edit_name_btn.clicked.connect(self._edit_tag_name) - self.edit_name_btn.setEnabled(False) - btn_row.addWidget(self.edit_name_btn) - - self.change_color_btn = QPushButton("&" + strings._("change_color")) - self.change_color_btn.clicked.connect(self._change_tag_color) - self.change_color_btn.setEnabled(False) - btn_row.addWidget(self.change_color_btn) - - self.delete_btn = QPushButton("&" + strings._("delete_tag")) - self.delete_btn.clicked.connect(self._delete_tag) - self.delete_btn.setEnabled(False) - btn_row.addWidget(self.delete_btn) - - btn_row.addStretch(1) - layout.addLayout(btn_row) - - # Close button - close_row = QHBoxLayout() - close_row.addStretch(1) - close_btn = QPushButton(strings._("close")) - close_btn.clicked.connect(self.accept) - close_row.addWidget(close_btn) - layout.addLayout(close_row) - - self._populate(focus_tag) - - def _populate(self, focus_tag: str | None): - # Disable sorting during population for better performance - was_sorting = self.tree.isSortingEnabled() - self.tree.setSortingEnabled(False) - self.tree.clear() - tags = self._db.list_tags() - focus_item = None - - for tag_id, name, color in tags: - # Create the tree item - root = QTreeWidgetItem([name, "", ""]) - root.setData( - 0, - Qt.ItemDataRole.UserRole, - {"type": "tag", "id": tag_id, "name": name, "color": color}, - ) - - # Set background color for the second column to show the tag color - bg_color = QColor(color) - root.setBackground(1, bg_color) - - # Calculate luminance and set contrasting text color - # Using relative luminance formula (ITU-R BT.709) - luminance = ( - 0.2126 * bg_color.red() - + 0.7152 * bg_color.green() - + 0.0722 * bg_color.blue() - ) / 255.0 - text_color = QColor(0, 0, 0) if luminance > 0.5 else QColor(255, 255, 255) - root.setForeground(1, text_color) - root.setText(1, color) # Also show the hex code - root.setTextAlignment(1, Qt.AlignCenter) - - self.tree.addTopLevelItem(root) - - pages = self._db.get_pages_for_tag(name) - for date_iso, _content in pages: - child = QTreeWidgetItem(["", "", date_iso]) - child.setData( - 0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso} - ) - root.addChild(child) - - if focus_tag and name.lower() == focus_tag.lower(): - focus_item = root - - if focus_item: - self.tree.expandItem(focus_item) - self.tree.setCurrentItem(focus_item) - - # Re-enable sorting after population - self.tree.setSortingEnabled(was_sorting) - - def _on_item_clicked(self, item: QTreeWidgetItem, column: int): - """Enable/disable buttons based on selection""" - data = item.data(0, Qt.ItemDataRole.UserRole) - if isinstance(data, dict): - if data.get("type") == "tag": - self.edit_name_btn.setEnabled(True) - self.change_color_btn.setEnabled(True) - self.delete_btn.setEnabled(True) - else: - self.edit_name_btn.setEnabled(False) - self.change_color_btn.setEnabled(False) - self.delete_btn.setEnabled(False) - - def _on_item_activated(self, item: QTreeWidgetItem, column: int): - data = item.data(0, Qt.ItemDataRole.UserRole) - if isinstance(data, dict): - if data.get("type") == "page": - date_iso = data.get("date") - if date_iso: - self.openDateRequested.emit(date_iso) - self.accept() - - def _add_a_tag(self): - """Add a new tag""" - - new_name, ok = QInputDialog.getText( - self, strings._("add_a_tag"), strings._("new_tag_name"), text="" - ) - - if ok and new_name: - color = QColorDialog.getColor(QColor(), self) - if color.isValid(): - try: - self._db.add_tag(new_name, color.name()) - self._populate(None) - self.tagsModified.emit() - except IntegrityError as e: - QMessageBox.critical(self, strings._("db_database_error"), str(e)) - - def _edit_tag_name(self): - """Edit the name of the selected tag""" - item = self.tree.currentItem() - if not item: - return - - data = item.data(0, Qt.ItemDataRole.UserRole) - if not isinstance(data, dict) or data.get("type") != "tag": - return - - tag_id = data["id"] - old_name = data["name"] - color = data["color"] - - new_name, ok = QInputDialog.getText( - self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name - ) - - if ok and new_name and new_name != old_name: - try: - self._db.update_tag(tag_id, new_name, color) - self._populate(None) - self.tagsModified.emit() - except IntegrityError as e: - QMessageBox.critical(self, strings._("db_database_error"), str(e)) - - def _change_tag_color(self): - """Change the color of the selected tag""" - item = self.tree.currentItem() - if not item: - return - - data = item.data(0, Qt.ItemDataRole.UserRole) - if not isinstance(data, dict) or data.get("type") != "tag": - return - - tag_id = data["id"] - name = data["name"] - current_color = data["color"] - - color = QColorDialog.getColor(QColor(current_color), self) - if color.isValid(): - try: - self._db.update_tag(tag_id, name, color.name()) - self._populate(None) - self.tagsModified.emit() - except IntegrityError as e: - QMessageBox.critical(self, strings._("db_database_error"), str(e)) - - def _delete_tag(self): - """Delete the selected tag""" - item = self.tree.currentItem() - if not item: - return - - data = item.data(0, Qt.ItemDataRole.UserRole) - if not isinstance(data, dict) or data.get("type") != "tag": - return - - tag_id = data["id"] - name = data["name"] - - # Confirm deletion - reply = QMessageBox.question( - self, - strings._("delete_tag"), - strings._("delete_tag_confirm").format(name=name), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - - if reply == QMessageBox.Yes: - self._db.delete_tag(tag_id) - self._populate(None) - self.tagsModified.emit() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py deleted file mode 100644 index 423bd06..0000000 --- a/bouquin/tags_widget.py +++ /dev/null @@ -1,259 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QVBoxLayout, - QWidget, - QToolButton, - QLabel, - QLineEdit, - QSizePolicy, - QStyle, - QCompleter, -) - -from . import strings -from .db import DBManager -from .flow_layout import FlowLayout - - -class TagChip(QFrame): - removeRequested = Signal(int) # tag_id - clicked = Signal(str) # tag name - - def __init__( - self, - tag_id: int, - name: str, - color: str, - parent: QWidget | None = None, - show_remove: bool = True, - ): - super().__init__(parent) - self._id = tag_id - self._name = name - - self.setObjectName("TagChip") - - self.setFrameShape(QFrame.StyledPanel) - self.setFrameShadow(QFrame.Raised) - - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 2, 4, 2) - layout.setSpacing(4) - - color_lbl = QLabel() - color_lbl.setFixedSize(10, 10) - color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;") - layout.addWidget(color_lbl) - - name_lbl = QLabel(name) - layout.addWidget(name_lbl) - - if show_remove: - btn = QToolButton() - btn.setText("×") - btn.setAutoRaise(True) - btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) - layout.addWidget(btn) - - self.setCursor(Qt.PointingHandCursor) - - @property - def tag_id(self) -> int: - return self._id - - def mouseReleaseEvent(self, ev): - if ev.button() == Qt.LeftButton: - self.clicked.emit(self._name) - try: - super().mouseReleaseEvent(ev) - except RuntimeError: - pass - - -class PageTagsWidget(QFrame): - """ - Collapsible per-page tag editor shown in the left sidebar. - Now displays tag chips even when collapsed. - """ - - tagActivated = Signal(str) # tag name - tagAdded = Signal() # emitted when a tag is added to trigger autosave - - def __init__(self, db: DBManager, parent: QWidget | None = None): - super().__init__(parent) - self._db = db - self._current_date: Optional[str] = None - - self.setFrameShape(QFrame.StyledPanel) - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - - # Header (toggle + manage button) - self.toggle_btn = QToolButton() - self.toggle_btn.setText(strings._("tags")) - self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - self.toggle_btn.setCheckable(True) - self.toggle_btn.setChecked(False) - self.toggle_btn.setArrowType(Qt.RightArrow) - self.toggle_btn.clicked.connect(self._on_toggle) - - self.manage_btn = QToolButton() - self.manage_btn.setIcon( - self.style().standardIcon(QStyle.SP_FileDialogDetailedView) - ) - self.manage_btn.setToolTip(strings._("manage_tags")) - self.manage_btn.setAutoRaise(True) - self.manage_btn.clicked.connect(self._open_manager) - - header = QHBoxLayout() - header.setContentsMargins(0, 0, 0, 0) - header.addWidget(self.toggle_btn) - header.addStretch(1) - header.addWidget(self.manage_btn) - - # Body (chips + add line - only visible when expanded) - self.body = QWidget() - self.body_layout = QVBoxLayout(self.body) - self.body_layout.setContentsMargins(0, 4, 0, 0) - self.body_layout.setSpacing(4) - - # Chips container - self.chip_container = QWidget() - self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4) - self.body_layout.addWidget(self.chip_container) - - self.add_edit = QLineEdit() - self.add_edit.setPlaceholderText(strings._("add_tag_placeholder")) - self.add_edit.returnPressed.connect(self._on_add_tag) - - # Setup autocomplete - self._setup_autocomplete() - - self.body_layout.addWidget(self.add_edit) - self.body.setVisible(False) - - main = QVBoxLayout(self) - main.setContentsMargins(0, 0, 0, 0) - main.addLayout(header) - main.addWidget(self.body) - - # ----- external API ------------------------------------------------ - - def set_current_date(self, date_iso: str) -> None: - self._current_date = date_iso - # Only reload tags if expanded - if self.toggle_btn.isChecked(): - self._reload_tags() - else: - self._clear_chips() # Clear chips when collapsed - self._setup_autocomplete() # Update autocomplete with all available tags - - # ----- internals --------------------------------------------------- - - def _setup_autocomplete(self) -> None: - """Setup autocomplete for the tag input with all existing tags""" - all_tags = [name for _, name, _ in self._db.list_tags()] - completer = QCompleter(all_tags, self.add_edit) - completer.setCaseSensitivity(Qt.CaseInsensitive) - self.add_edit.setCompleter(completer) - - def _on_toggle(self, checked: bool) -> None: - self.body.setVisible(checked) - self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) - if checked: - if self._current_date: - self._reload_tags() - self.add_edit.setFocus() - - def _clear_chips(self) -> None: - while self.chip_layout.count(): - item = self.chip_layout.takeAt(0) - w = item.widget() - if w is not None: - w.deleteLater() - - def _reload_tags(self) -> None: - if not self._current_date: - self._clear_chips() - return - - self._clear_chips() - tags = self._db.get_tags_for_page(self._current_date) - for tag_id, name, color in tags: - # Always show remove button since chips only visible when expanded - chip = TagChip(tag_id, name, color, self, show_remove=True) - chip.removeRequested.connect(self._remove_tag) - chip.clicked.connect(self._on_chip_clicked) - self.chip_layout.addWidget(chip) - chip.show() - chip.adjustSize() - - # Force complete layout recalculation - self.chip_layout.invalidate() - self.chip_layout.activate() - self.chip_container.updateGeometry() - self.updateGeometry() - # Process pending events to ensure layout is applied - from PySide6.QtCore import QCoreApplication - - QCoreApplication.processEvents() - - def _on_add_tag(self) -> None: - if not self._current_date: - return - - # If the completer popup is visible and user pressed Enter, - # the completer will handle it - don't process it again - if self.add_edit.completer() and self.add_edit.completer().popup().isVisible(): - return - - new_tag = self.add_edit.text().strip() - if not new_tag: - return - - # Get existing tags for current page - existing = [ - name for _, name, _ in self._db.get_tags_for_page(self._current_date) - ] - - # Check for duplicates (case-insensitive) - if any(tag.lower() == new_tag.lower() for tag in existing): - self.add_edit.clear() - return - - existing.append(new_tag) - self._db.set_tags_for_page(self._current_date, existing) - - self.add_edit.clear() - self._reload_tags() - self._setup_autocomplete() # Update autocomplete list - - # Signal that a tag was added so main window can trigger autosave - self.tagAdded.emit() - - def _remove_tag(self, tag_id: int) -> None: - if not self._current_date: - return - tags = self._db.get_tags_for_page(self._current_date) - remaining = [name for (tid, name, _color) in tags if tid != tag_id] - self._db.set_tags_for_page(self._current_date, remaining) - self._reload_tags() - - def _open_manager(self) -> None: - from .tag_browser import TagBrowserDialog - - dlg = TagBrowserDialog(self._db, self) - dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso)) - if dlg.exec(): - # Reload tags after manager closes to pick up any changes - if self._current_date: - self._reload_tags() - self._setup_autocomplete() - - def _on_chip_clicked(self, name: str) -> None: - self.tagActivated.emit(name) diff --git a/bouquin/theme.py b/bouquin/theme.py index 305f249..ddd9fa5 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -2,9 +2,8 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum from PySide6.QtGui import QPalette, QColor, QGuiApplication -from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget +from PySide6.QtWidgets import QApplication from PySide6.QtCore import QObject, Signal -from weakref import WeakSet class Theme(Enum): @@ -27,9 +26,6 @@ class ThemeManager(QObject): super().__init__() self._app = app self._cfg = cfg - self._current = None - self._calendars: "WeakSet[QCalendarWidget]" = WeakSet() - self._lock_overlays: "WeakSet[QWidget]" = WeakSet() # Follow OS if supported (Qt 6+) hints = QGuiApplication.styleHints() @@ -39,20 +35,6 @@ class ThemeManager(QObject): and self.apply(self._cfg.theme) ) - def _is_system_dark(self) -> bool: - pal = QGuiApplication.palette() - # Heuristic: dark windows/backgrounds mean dark system theme - return pal.color(QPalette.Window).lightness() < 128 - - def _restyle_registered(self) -> None: - for cal in list(self._calendars): - if cal is not None: - self._apply_calendar_theme(cal) - - for overlay in list(self._lock_overlays): - if overlay is not None: - self._apply_lock_overlay_theme(overlay) - def current(self) -> Theme: return self._cfg.theme @@ -61,34 +43,28 @@ class ThemeManager(QObject): self.apply(theme) def apply(self, theme: Theme): - # Resolve "system" into a concrete theme - resolved = theme + # Resolve "system" if theme == Theme.SYSTEM: - resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT - - if resolved == Theme.DARK: - pal = self._dark_palette() - else: - pal = self._light_palette() + hints = QGuiApplication.styleHints() + scheme = getattr(hints, "colorScheme", None) + if callable(scheme): + scheme = hints.colorScheme() + # 0=Light, 1=Dark; fall back to Light + theme = Theme.DARK if scheme == 1 else Theme.LIGHT # Always use Fusion so palette applies consistently cross-platform - QApplication.setStyle("Fusion") + self._app.setStyle("Fusion") - self._app.setPalette(pal) - self._current = resolved - # Re-style any registered widgets - self._restyle_registered() - self.themeChanged.emit(self._current) + if theme == Theme.DARK: + pal = self._dark_palette() + self._app.setPalette(pal) + self._app.setStyleSheet("") + else: + pal = self._light_palette() + self._app.setPalette(pal) + self._app.setStyleSheet("") - def register_calendar(self, cal: QCalendarWidget) -> None: - """Start theming calendar and keep it in sync with theme changes.""" - self._calendars.add(cal) - self._apply_calendar_theme(cal) - - def register_lock_overlay(self, overlay: QWidget) -> None: - """Start theming lock overlay and keep it in sync with theme changes.""" - self._lock_overlays.add(overlay) - self._apply_lock_overlay_theme(overlay) + self.themeChanged.emit(theme) # ----- Palettes ----- def _dark_palette(self) -> QPalette: @@ -99,24 +75,17 @@ class ThemeManager(QObject): disabled = QColor(127, 127, 127) focus = QColor(42, 130, 218) - # Base surfaces pal.setColor(QPalette.Window, window) + pal.setColor(QPalette.WindowText, text) pal.setColor(QPalette.Base, base) pal.setColor(QPalette.AlternateBase, window) - - # Text - pal.setColor(QPalette.WindowText, text) pal.setColor(QPalette.ToolTipBase, window) pal.setColor(QPalette.ToolTipText, text) pal.setColor(QPalette.Text, text) pal.setColor(QPalette.PlaceholderText, disabled) - pal.setColor(QPalette.ButtonText, text) - - # Buttons/frames pal.setColor(QPalette.Button, window) + pal.setColor(QPalette.ButtonText, text) pal.setColor(QPalette.BrightText, QColor(255, 84, 84)) - - # Links / selection pal.setColor(QPalette.Highlight, focus) pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value)) @@ -125,136 +94,11 @@ class ThemeManager(QObject): return pal def _light_palette(self) -> QPalette: - pal = QPalette() - - # Base surfaces - pal.setColor(QPalette.Window, QColor("#ffffff")) - pal.setColor(QPalette.Base, QColor("#ffffff")) - pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5")) - - # Text - pal.setColor(QPalette.WindowText, QColor("#000000")) - pal.setColor(QPalette.Text, QColor("#000000")) - pal.setColor(QPalette.ButtonText, QColor("#000000")) - - # Buttons/frames - pal.setColor(QPalette.Button, QColor("#f0f0f0")) - pal.setColor(QPalette.Mid, QColor("#9e9e9e")) - - # Links / selection - pal.setColor(QPalette.Highlight, QColor("#1a73e8")) - pal.setColor(QPalette.HighlightedText, QColor("#ffffff")) - pal.setColor(QPalette.Link, QColor("#1a73e8")) - pal.setColor(QPalette.LinkVisited, QColor("#6b4ca5")) - + # Let Qt provide its default light palette, but nudge a couple roles + pal = self._app.style().standardPalette() + pal.setColor(QPalette.Highlight, QColor(0, 120, 215)) + pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + pal.setColor( + QPalette.Link, QColor("#1a73e8") + ) # Light blue for links in light mode return pal - - def _apply_calendar_theme(self, cal: QCalendarWidget) -> None: - """Use orange accents on the calendar in dark mode only.""" - app_pal = QApplication.instance().palette() - is_dark = (self.current() == Theme.DARK) or ( - self.current() == Theme.SYSTEM and self._is_system_dark() - ) - - if is_dark: - highlight_css = Theme.ORANGE_ANCHOR.value - highlight = QColor(highlight_css) - black = QColor(0, 0, 0) - - # Per-widget palette: selection color inside the date grid - pal = cal.palette() - pal.setColor(QPalette.Highlight, highlight) - pal.setColor(QPalette.HighlightedText, black) - cal.setPalette(pal) - - # Stylesheet: nav bar + selected-day background - cal.setStyleSheet(self._calendar_qss(highlight_css)) - else: - # Back to app defaults in light/system-light - cal.setPalette(app_pal) - cal.setStyleSheet("") - - cal.update() - - def _calendar_qss(self, highlight_css: str) -> str: - return f""" - QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }} - QCalendarWidget QToolButton {{ color: black; }} - QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }} - /* Selected day color in the table view */ - QCalendarWidget QTableView:enabled {{ - selection-background-color: {highlight_css}; - selection-color: black; - }} - /* Keep weekday header readable */ - QCalendarWidget QTableView QHeaderView::section {{ - background: transparent; - color: palette(windowText); - }} - """ - - def _apply_lock_overlay_theme(self, overlay: QWidget) -> None: - """ - Style the LockOverlay (objectName 'LockOverlay') using theme colors. - Dark: opaque black bg, orange accent; Light: translucent scrim, palette-driven colors. - """ - pal = QApplication.instance().palette() - is_dark = (self.current() == Theme.DARK) or ( - self.current() == Theme.SYSTEM and self._is_system_dark() - ) - - if is_dark: - # Use the link color as the accent - accent = pal.color(QPalette.Link) - r, g, b = accent.red(), accent.green(), accent.blue() - accent_hex = accent.name() - - qss = f""" -#LockOverlay {{ background-color: rgb(0,0,0); }} -#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }} - -#LockOverlay QPushButton#unlockButton {{ - color: {accent_hex}; - background-color: rgba({r},{g},{b},0.10); - border: 1px solid {accent_hex}; - border-radius: 8px; - padding: 8px 16px; -}} -#LockOverlay QPushButton#unlockButton:hover {{ - background-color: rgba({r},{g},{b},0.16); - border-color: {accent_hex}; -}} -#LockOverlay QPushButton#unlockButton:pressed {{ - background-color: rgba({r},{g},{b},0.24); -}} -#LockOverlay QPushButton#unlockButton:focus {{ - outline: none; - border-color: {accent_hex}; -}} -""" - else: - qss = """ -#LockOverlay { background-color: rgba(0,0,0,120); } -#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; } - -#LockOverlay QPushButton#unlockButton { - color: palette(button-text); - background-color: rgba(255,255,255,0.92); - border: 1px solid rgba(0,0,0,0.25); - border-radius: 8px; - padding: 8px 16px; -} -#LockOverlay QPushButton#unlockButton:hover { - background-color: rgba(255,255,255,1.0); - border-color: rgba(0,0,0,0.35); -} -#LockOverlay QPushButton#unlockButton:pressed { - background-color: rgba(245,245,245,1.0); -} -#LockOverlay QPushButton#unlockButton:focus { - outline: none; - border-color: palette(highlight); -} -""" - overlay.setStyleSheet(qss) - overlay.update() diff --git a/bouquin/time_log.py b/bouquin/time_log.py deleted file mode 100644 index a76ccf6..0000000 --- a/bouquin/time_log.py +++ /dev/null @@ -1,1217 +0,0 @@ -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 8873ffd..acf0413 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -4,8 +4,6 @@ from PySide6.QtCore import Signal, Qt from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup from PySide6.QtWidgets import QToolBar -from . import strings - class ToolBar(QToolBar): boldRequested = Signal() @@ -18,108 +16,83 @@ class ToolBar(QToolBar): checkboxesRequested = Signal() historyRequested = Signal() insertImageRequested = Signal() - alarmRequested = Signal() - timerRequested = Signal() - fontSizeLargerRequested = Signal() - fontSizeSmallerRequested = Signal() def __init__(self, parent=None): - super().__init__(strings._("toolbar_format"), parent) - self.setObjectName(strings._("toolbar_format")) + super().__init__("Format", parent) + self.setObjectName("Format") self.setToolButtonStyle(Qt.ToolButtonTextOnly) self._build_actions() self._apply_toolbar_styles() def _build_actions(self): self.actBold = QAction("B", self) - self.actBold.setToolTip(strings._("toolbar_bold")) + self.actBold.setToolTip("Bold") self.actBold.setCheckable(True) self.actBold.setShortcut(QKeySequence.Bold) self.actBold.triggered.connect(self.boldRequested) self.actItalic = QAction("I", self) - self.actItalic.setToolTip(strings._("toolbar_italic")) + self.actItalic.setToolTip("Italic") self.actItalic.setCheckable(True) self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.triggered.connect(self.italicRequested) self.actStrike = QAction("S", self) - self.actStrike.setToolTip(strings._("toolbar_strikethrough")) + self.actStrike.setToolTip("Strikethrough") self.actStrike.setCheckable(True) self.actStrike.setShortcut("Ctrl+-") self.actStrike.triggered.connect(self.strikeRequested) self.actCode = QAction("", self) - self.actCode.setToolTip(strings._("toolbar_code_block")) + self.actCode.setToolTip("Code block") self.actCode.setShortcut("Ctrl+`") self.actCode.triggered.connect(self.codeRequested) # Headings self.actH1 = QAction("H1", self) - self.actH1.setToolTip(strings._("toolbar_heading") + " 1") + self.actH1.setToolTip("Heading 1") self.actH1.setCheckable(True) self.actH1.setShortcut("Ctrl+1") self.actH1.triggered.connect(lambda: self.headingRequested.emit(24)) self.actH2 = QAction("H2", self) - self.actH2.setToolTip(strings._("toolbar_heading") + " 2") + self.actH2.setToolTip("Heading 2") self.actH2.setCheckable(True) self.actH2.setShortcut("Ctrl+2") self.actH2.triggered.connect(lambda: self.headingRequested.emit(18)) self.actH3 = QAction("H3", self) - self.actH3.setToolTip(strings._("toolbar_heading") + " 3") + self.actH3.setToolTip("Heading 3") self.actH3.setCheckable(True) self.actH3.setShortcut("Ctrl+3") self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) - self.actNormal = QAction("P", self) - self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text")) + self.actNormal = QAction("N", self) + self.actNormal.setToolTip("Normal paragraph text") self.actNormal.setCheckable(True) - self.actNormal.setShortcut("Ctrl+.") + self.actNormal.setShortcut("Ctrl+N") self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) - self.actFontSmaller = QAction("P-", self) - self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller")) - self.actFontSmaller.setShortcut("Ctrl+Shift+-") - self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested) - - self.actFontLarger = QAction("P+", self) - self.actFontLarger.setToolTip(strings._("toolbar_font_larger")) - self.actFontLarger.setShortcut("Ctrl+Shift+=") - self.actFontLarger.triggered.connect(self.fontSizeLargerRequested) - # Lists self.actBullets = QAction("•", self) - self.actBullets.setToolTip(strings._("toolbar_bulleted_list")) + self.actBullets.setToolTip("Bulleted list") self.actBullets.setCheckable(True) self.actBullets.triggered.connect(self.bulletsRequested) self.actNumbers = QAction("1.", self) - self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) + self.actNumbers.setToolTip("Numbered list") self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) self.actCheckboxes = QAction("☐", self) - self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) + self.actCheckboxes.setToolTip("Toggle checkboxes") self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images - self.actInsertImg = QAction("📸", self) - self.actInsertImg.setToolTip(strings._("insert_images")) + self.actInsertImg = QAction("Image", self) + self.actInsertImg.setToolTip("Insert image") self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.triggered.connect(self.insertImageRequested) # History button - self.actHistory = QAction("🔁", self) - self.actHistory.setToolTip(strings._("history")) + self.actHistory = QAction("History", self) self.actHistory.triggered.connect(self.historyRequested) - # Alarm / reminder - self.actAlarm = QAction("⏰", self) - self.actAlarm.setToolTip(strings._("toolbar_alarm")) - self.actAlarm.triggered.connect(self.alarmRequested) - - # Focus timer - self.actTimer = QAction("⌛", self) - self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) - self.actTimer.triggered.connect(self.timerRequested) - # Set exclusive buttons in QActionGroups self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) @@ -151,14 +124,10 @@ class ToolBar(QToolBar): self.actH2, self.actH3, self.actNormal, - self.actFontSmaller, - self.actFontLarger, self.actBullets, self.actNumbers, self.actCheckboxes, self.actInsertImg, - self.actAlarm, - self.actTimer, self.actHistory, ] ) @@ -175,19 +144,14 @@ class ToolBar(QToolBar): self._style_letter_button(self.actH1, "H1") self._style_letter_button(self.actH2, "H2") self._style_letter_button(self.actH3, "H3") - self._style_letter_button(self.actNormal, "P") - self._style_letter_button(self.actFontSmaller, "P-") - self._style_letter_button(self.actFontLarger, "P+") + self._style_letter_button(self.actNormal, "N") # Lists self._style_letter_button(self.actBullets, "•") self._style_letter_button(self.actNumbers, "1.") - self._style_letter_button(self.actCheckboxes, "☐") - self._style_letter_button(self.actAlarm, "⏰") - self._style_letter_button(self.actTimer, "⌛") # History - self._style_letter_button(self.actHistory, "🔁") + self._style_letter_button(self.actHistory, "View History") def _style_letter_button( self, diff --git a/bouquin/version_check.py b/bouquin/version_check.py deleted file mode 100644 index b2010d5..0000000 --- a/bouquin/version_check.py +++ /dev/null @@ -1,412 +0,0 @@ -from __future__ import annotations - -import importlib.metadata -import os -import re -import subprocess # nosec -import tempfile -from pathlib import Path - -import requests -from importlib.resources import files -from PySide6.QtCore import QStandardPaths, Qt -from PySide6.QtWidgets import ( - QApplication, - QMessageBox, - QWidget, - QProgressDialog, -) -from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication -from PySide6.QtSvg import QSvgRenderer - -from .settings import APP_NAME -from . import strings - - -# Where to fetch the latest version string from -VERSION_URL = "https://mig5.net/bouquin/version.txt" - -# Name of the installed distribution according to pyproject.toml -# (used with importlib.metadata.version) -DIST_NAME = "bouquin" - -# Base URL where AppImages are hosted -APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download" - -# Where we expect to find the bundled public key, relative to the *installed* package. -GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc") - - -class VersionChecker: - """ - Handles: - * showing the version dialog - * checking for updates - * downloading & verifying a new AppImage - - All dialogs use `parent` as their parent widget. - """ - - def __init__(self, parent: QWidget | None = None): - self._parent = parent - - # ---------- Version helpers ---------- # - - def _logo_pixmap(self, logical_size: int = 96) -> QPixmap: - """ - Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp. - """ - svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg" - - # Logical size (what Qt layouts see) - dpr = QGuiApplication.primaryScreen().devicePixelRatio() - img_size = int(logical_size * dpr) - - image = QImage(img_size, img_size, QImage.Format_ARGB32) - image.fill(Qt.transparent) - - renderer = QSvgRenderer(str(svg_path)) - painter = QPainter(image) - renderer.render(painter) - painter.end() - - pixmap = QPixmap.fromImage(image) - pixmap.setDevicePixelRatio(dpr) - return pixmap - - def current_version(self) -> str: - """ - Return the current app version as reported by importlib.metadata - """ - try: - return importlib.metadata.version(DIST_NAME) - except importlib.metadata.PackageNotFoundError: - # Fallback for editable installs / dev trees - return "0.0.0" - - @staticmethod - def _parse_version(v: str) -> tuple[int, ...]: - """ - Very small helper to compare simple semantic versions like 1.2.3. - Extracts numeric components and returns them as a tuple. - """ - parts = re.findall(r"\d+", v) - if not parts: - return (0,) - return tuple(int(p) for p in parts) - - def _is_newer_version(self, available: str, current: str) -> bool: - """ - True if `available` > `current` according to _parse_version. - """ - return self._parse_version(available) > self._parse_version(current) - - # ---------- Public entrypoint for Help → Version ---------- # - - def show_version_dialog(self) -> None: - """ - Show the Version dialog with a 'Check for updates' button. - """ - version = self.current_version() - version_formatted = f"{APP_NAME} {version}" - - box = QMessageBox(self._parent) - box.setWindowTitle(strings._("version")) - - box.setIconPixmap(self._logo_pixmap(96)) - - box.setText(version_formatted) - - check_button = box.addButton( - strings._("check_for_updates"), QMessageBox.ActionRole - ) - box.addButton(QMessageBox.Close) - - box.exec() - - if box.clickedButton() is check_button: - self.check_for_updates() - - # ---------- Core update logic ---------- # - - def check_for_updates(self) -> None: - """ - Fetch VERSION_URL, compare against the current version, and optionally - download + verify a new AppImage. - """ - current = self.current_version() - - try: - resp = requests.get(VERSION_URL, timeout=10) - resp.raise_for_status() - available_raw = resp.text.strip() - except Exception as e: - QMessageBox.warning( - self._parent, - strings._("update"), - strings._("could_not_check_for_updates") + str(e), - ) - return - - if not available_raw: - QMessageBox.warning( - self._parent, - strings._("update"), - strings._("update_server_returned_an_empty_version_string"), - ) - return - - if not self._is_newer_version(available_raw, current): - QMessageBox.information( - self._parent, - strings._("update"), - strings._("you_are_running_the_latest_version") + f"({current}).", - ) - return - - # Newer version is available - reply = QMessageBox.question( - self._parent, - strings._("update"), - ( - strings._("there_is_a_new_version_available") - + available_raw - + "\n\n" - + strings._("download_the_appimage") - ), - QMessageBox.Yes | QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - - self._download_and_verify_appimage(available_raw) - - # ---------- Download + verification helpers ---------- # - def _download_file( - self, - url: str, - dest_path: Path, - timeout: int = 30, - progress: QProgressDialog | None = None, - label: str | None = None, - ) -> None: - """ - Stream a URL to a local file, optionally updating a QProgressDialog. - If the user cancels via the dialog, raises RuntimeError. - """ - resp = requests.get(url, timeout=timeout, stream=True) - resp.raise_for_status() - - dest_path.parent.mkdir(parents=True, exist_ok=True) - - total_bytes: int | None = None - content_length = resp.headers.get("Content-Length") - if content_length is not None: - try: - total_bytes = int(content_length) - except ValueError: - total_bytes = None - - if progress is not None: - progress.setLabelText( - label or strings._("downloading") + f" {dest_path.name}..." - ) - # Unknown size → busy indicator; known size → real range - if total_bytes is not None and total_bytes > 0: - progress.setRange(0, total_bytes) - else: - progress.setRange(0, 0) # pragma: no cover - progress.setValue(0) - progress.show() - QApplication.processEvents() - - downloaded = 0 - with dest_path.open("wb") as f: - for chunk in resp.iter_content(chunk_size=8192): - if not chunk: - continue # pragma: no cover - - f.write(chunk) - downloaded += len(chunk) - - if progress is not None: - if total_bytes is not None and total_bytes > 0: - progress.setValue(downloaded) - else: - # Just bump a little so the dialog looks alive - progress.setValue(progress.value() + 1) # pragma: no cover - QApplication.processEvents() - - if progress.wasCanceled(): - raise RuntimeError(strings._("download_cancelled")) - - if progress is not None and total_bytes is not None and total_bytes > 0: - progress.setValue(total_bytes) - QApplication.processEvents() - - def _download_and_verify_appimage(self, version: str) -> None: - """ - Download the AppImage + its GPG signature to the user's Downloads dir, - then verify it with a bundled public key. - """ - # Where to put the file - download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) - if not download_dir: - download_dir = os.path.expanduser("~/Downloads") - download_dir = Path(download_dir) - download_dir.mkdir(parents=True, exist_ok=True) - - # Construct AppImage filename and URLs - appimage_path = download_dir / "Bouquin.AppImage" - sig_path = Path(str(appimage_path) + ".asc") - - appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage" - sig_url = f"{appimage_url}.asc" - - # Progress dialog covering both downloads - progress = QProgressDialog( - "Downloading update...", - "Cancel", - 0, - 100, - self._parent, - ) - progress.setWindowTitle(strings._("update")) - progress.setWindowModality(Qt.WindowModal) - progress.setAutoClose(False) - progress.setAutoReset(False) - - try: - # AppImage download - self._download_file( - appimage_url, - appimage_path, - progress=progress, - label=strings._("downloading") + " Bouquin.AppImage...", - ) - # Signature download (usually tiny, but we still show it) - self._download_file( - sig_url, - sig_path, - progress=progress, - label=strings._("downloading") + " signature...", - ) - except RuntimeError: - # User cancelled - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - progress.close() - QMessageBox.information( - self._parent, - strings._("update"), - strings._("download_cancelled"), - ) - return - except Exception as e: - # Other error - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - progress.close() - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("failed_to_download_update") + str(e), - ) - return - - progress.close() - - # Load the bundled public key - try: - pkg, *rel = GPG_PUBKEY_RESOURCE - pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes() - except Exception as e: # pragma: no cover - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("could_not_read_bundled_gpg_public_key") + str(e), - ) - # On failure, delete the downloaded files for safety - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() - except OSError: # pragma: no cover - pass - return - - # Use a temporary GNUPGHOME so we don't touch the user's main keyring - try: - with tempfile.TemporaryDirectory() as gnupg_home: - pubkey_path = Path(gnupg_home) / "pubkey.asc" - pubkey_path.write_bytes(pubkey_bytes) - - # Import the key - subprocess.run( - ["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) # nosec - - # Verify the signature - subprocess.run( - [ - "gpg", - "--homedir", - gnupg_home, - "--verify", - str(sig_path), - str(appimage_path), - ], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) # nosec - except FileNotFoundError: - # gpg not installed / not on PATH - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("could_not_find_gpg_executable"), - ) - return - except subprocess.CalledProcessError as e: - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("gpg_signature_verification_failed") - + e.stderr.decode(errors="ignore"), - ) - return - - # Success - QMessageBox.information( - self._parent, - strings._("update"), - strings._("downloaded_and_verified_new_appimage") + str(appimage_path), - ) diff --git a/find_unused_strings.py b/find_unused_strings.py deleted file mode 100755 index 5341001..0000000 --- a/find_unused_strings.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import ast -import json -from pathlib import Path -from typing import Dict, Set - - -BASE_DIR = Path(__file__).resolve().parent / "bouquin" -LOCALES_DIR = BASE_DIR / "locales" - - -def load_json_keys(locale: str) -> Set[str]: - """Load all keys from the given locale JSON file.""" - path = LOCALES_DIR / f"{locale}.json" - with path.open(encoding="utf-8") as f: - data = json.load(f) - return set(data.keys()) - - -class KeyParamFinder(ast.NodeVisitor): - """ - First pass: - For each function/method, figure out which parameters are later passed - into _(), translated(), or strings._(). - - Example: in your _prompt_name, it discovers that title_key and label_key - are translation-key parameters. - """ - - def __init__(self) -> None: - # func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]} - self.func_info: Dict[str, dict] = {} - self.current_func_name_stack: list[str] = [] - self.current_param_positions_stack: list[Dict[str, int]] = [] - self.current_class_stack: list[str] = [] - - # Track when we're inside a class so we can treat "self" specially - def visit_ClassDef(self, node: ast.ClassDef) -> None: - self.current_class_stack.append(node.name) - self.generic_visit(node) - self.current_class_stack.pop() - - def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - funcname = node.name - params = [arg.arg for arg in node.args.args] - - # If we're inside a class and there is at least one param, - # assume the first one is "self"/"cls" and is implicit at call sites. - is_method = bool(self.current_class_stack) and len(params) > 0 - - param_positions: Dict[str, int] = {} - for i, name in enumerate(params): - if is_method and i == 0: - # skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...) - continue - call_index = i - 1 if is_method else i - param_positions[name] = call_index - - self.current_func_name_stack.append(funcname) - self.current_param_positions_stack.append(param_positions) - - self.func_info.setdefault( - funcname, - { - "param_positions": param_positions, - "key_param_positions": set(), - }, - ) - # If the function name is reused, last definition wins - self.func_info[funcname]["param_positions"] = param_positions - - def _exit_function(self) -> None: - self.current_func_name_stack.pop() - self.current_param_positions_stack.pop() - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._enter_function(node) - self.generic_visit(node) - self._exit_function() - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._enter_function(node) - self.generic_visit(node) - self._exit_function() - - def visit_Call(self, node: ast.Call) -> None: - # Only care about calls *inside* functions - if not self.current_func_name_stack: - return self.generic_visit(node) - - func = node.func - func_name: str | None = None - - if isinstance(func, ast.Name): - func_name = func.id - elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): - # e.g. strings._(...) - func_name = f"{func.value.id}.{func.attr}" - - # Is this a translation call? - if func_name in {"_", "translated", "strings._"}: - cur_name = self.current_func_name_stack[-1] - param_positions = self.current_param_positions_stack[-1] - - # Positional first arg - if node.args: - first = node.args[0] - if isinstance(first, ast.Name): - pname = first.id - if pname in param_positions: - idx = param_positions[pname] - self.func_info[cur_name]["key_param_positions"].add(idx) - - # Keyword args, e.g. strings._(key=title_key) - for kw in node.keywords or []: - if isinstance(kw.value, ast.Name): - pname = kw.value.id - if pname in param_positions: - idx = param_positions[pname] - self.func_info[cur_name]["key_param_positions"].add(idx) - - self.generic_visit(node) - - -class UsedKeyCollector(ast.NodeVisitor): - """ - Second pass: - - Collect string literals passed directly to _()/translated()/strings._() - - Collect string literals passed into parameters that we know are - "translation-key parameters" of wrapper functions/methods. - """ - - def __init__(self, func_info: Dict[str, dict]) -> None: - self.func_info = func_info - self.used_keys: Set[str] = set() - - def visit_Call(self, node: ast.Call) -> None: - func = node.func - - def full_name(f: ast.expr) -> str | None: - if isinstance(f, ast.Name): - return f.id - if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): - return f"{f.value.id}.{f.attr}" - return None - - func_full = full_name(func) - - # 1) Direct translation calls like _("key") or strings._("key") - if func_full in {"_", "translated", "strings._"}: - if node.args: - first = node.args[0] - if isinstance(first, ast.Constant) and isinstance(first.value, str): - self.used_keys.add(first.value) - for kw in node.keywords or []: - if isinstance(kw.value, ast.Constant) and isinstance( - kw.value.value, str - ): - self.used_keys.add(kw.value.value) - - # 2) Wrapper calls: functions whose params we know are translation-key params - called_base_name: str | None = None - if isinstance(func, ast.Name): - called_base_name = func.id - elif isinstance(func, ast.Attribute): - called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name" - - if called_base_name in self.func_info: - info = self.func_info[called_base_name] - param_positions: Dict[str, int] = info["param_positions"] - key_positions: Set[int] = info["key_param_positions"] - - # positional args - for idx, arg in enumerate(node.args): - if ( - idx in key_positions - and isinstance(arg, ast.Constant) - and isinstance(arg.value, str) - ): - self.used_keys.add(arg.value) - - # keyword args - for kw in node.keywords or []: - if kw.arg is None: - continue # **kwargs, ignore - param_name = kw.arg - if param_name in param_positions: - idx = param_positions[param_name] - if idx in key_positions: - val = kw.value - if isinstance(val, ast.Constant) and isinstance(val.value, str): - self.used_keys.add(val.value) - - self.generic_visit(node) - - -def collect_used_keys() -> Set[str]: - """Parse all .py files and collect all translation keys used.""" - trees: list[ast.AST] = [] - - # Read and parse all Python files in this folder - for path in BASE_DIR.glob("*.py"): - # Optionally skip this script itself - if path.name == Path(__file__).name: - continue - src = path.read_text(encoding="utf-8") - tree = ast.parse(src, filename=str(path)) - trees.append(tree) - - # First pass: find which parameters are translation-key params - finder = KeyParamFinder() - for tree in trees: - finder.visit(tree) - - # Second pass: collect string literals passed to those parameters - collector = UsedKeyCollector(finder.func_info) - for tree in trees: - collector.visit(tree) - - return collector.used_keys - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Find missing or unused strings for a given locale" - ) - parser.add_argument( - "--locale", - type=str, - default="en", - help="Locale key e.g en, fr, it", - ) - args = parser.parse_args() - - json_keys = load_json_keys(args.locale) - used_keys = collect_used_keys() - - unused_keys = sorted(json_keys - used_keys) - missing_in_json = sorted(used_keys - json_keys) - - print("=== Unused keys in JSON (present in locales but never used in code) ===") - if unused_keys: - for k in unused_keys: - print(" ", k) - else: - print(" (none)") - - print("\n=== Keys used in code but missing from JSON ===") - if missing_in_json: - for k in missing_in_json: - print(" ", k) - else: - print(" (none)") - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock index b968699..e1c4ed5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,138 +1,5 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -[[package]] -name = "certifi" -version = "2025.11.12" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - [[package]] name = "colorama" version = "0.4.6" @@ -146,103 +13,115 @@ files = [ [[package]] name = "coverage" -version = "7.12.0" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" files = [ - {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, - {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, - {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, - {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, - {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, - {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, - {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, - {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, - {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, - {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, - {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, - {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, - {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, - {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, - {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, - {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, - {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, - {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, - {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, - {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, - {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, - {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, - {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, - {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.dependencies] @@ -251,20 +130,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] -[[package]] -name = "desktop-entry-lib" -version = "5.0" -description = "A library for working with .desktop files" -optional = false -python-versions = ">=3.10" -files = [ - {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, - {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, -] - -[package.extras] -xdg-desktop-portal = ["jeepney"] - [[package]] name = "exceptiongroup" version = "1.3.0" @@ -283,45 +148,16 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} test = ["pytest (>=6)"] [[package]] -name = "idna" -version = "3.11" -description = "Internationalized Domain Names in Applications (IDNA)" +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "markdown" -version = "3.10" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -files = [ - {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, - {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - [[package]] name = "packaging" version = "25.0" @@ -362,22 +198,6 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] -[[package]] -name = "pyproject-appimage" -version = "4.2" -description = "Generate AppImages from your Python projects" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"}, - {file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"}, -] - -[package.dependencies] -desktop-entry-lib = "*" -requests = "*" -tomli = {version = "*", markers = "python_version < \"3.11\""} - [[package]] name = "pyside6" version = "6.10.0" @@ -511,27 +331,6 @@ typing_extensions = "*" dev = ["pre-commit", "tox"] doc = ["sphinx", "sphinx_rtd_theme"] -[[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - [[package]] name = "shiboken6" version = "6.10.0" @@ -739,24 +538,7 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -[[package]] -name = "urllib3" -version = "2.5.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - [metadata] lock-version = "2.0" -python-versions = ">=3.10,<3.14" -content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df" +python-versions = ">=3.9,<3.14" +content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4" diff --git a/pyproject.toml b/pyproject.toml index ce8e44a..d3e82ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,16 @@ [tool.poetry] name = "bouquin" -version = "0.5.2" +version = "0.2.0" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" license = "GPL-3.0-or-later" repository = "https://git.mig5.net/mig5/bouquin" -packages = [{ include = "bouquin" }] -include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"] [tool.poetry.dependencies] -python = ">=3.10,<3.14" +python = ">=3.9,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" -requests = "^2.32.5" -markdown = "^3.10" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" @@ -25,20 +21,6 @@ pytest-qt = "^4.5.0" pytest-mock = "^3.15.1" pytest-cov = "^7.0.0" - -[tool.poetry.group.dev.dependencies] -pyproject-appimage = "^4.2" - -[tool.pyproject-appimage] -script = "bouquin" -output = "Bouquin.AppImage" -icon = "bouquin/icons/bouquin.svg" -rename-icon = "bouquin.png" -desktop-entry = "bouquin.desktop" - -[tool.vulture] -paths = ["bouquin", "vulture_ignorelist.py"] - [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/release.sh b/release.sh index a7e9c28..f3ca3bb 100755 --- a/release.sh +++ b/release.sh @@ -1,19 +1,10 @@ #!/bin/bash -set -eo pipefail +set -e rm -rf dist -# Publish to Pypi poetry build poetry publish -# Make AppImage -sudo apt-get install libfuse-dev -poetry run pyproject-appimage -mv Bouquin.AppImage dist/ - -# Sign packages for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done - -echo "Don't forget to update version string on remote server." diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..e0843e5 Binary files /dev/null and b/screenshot.png differ diff --git a/screenshot_dark.png b/screenshot_dark.png new file mode 100644 index 0000000..e9b4b8c Binary files /dev/null and b/screenshot_dark.png differ diff --git a/screenshots/history_diff.png b/screenshots/history_diff.png deleted file mode 100644 index 46c4591..0000000 Binary files a/screenshots/history_diff.png and /dev/null differ diff --git a/screenshots/history_preview.png b/screenshots/history_preview.png deleted file mode 100644 index 4475d94..0000000 Binary files a/screenshots/history_preview.png and /dev/null differ diff --git a/screenshots/screenshot.png b/screenshots/screenshot.png deleted file mode 100644 index a375c45..0000000 Binary files a/screenshots/screenshot.png and /dev/null differ diff --git a/screenshots/statistics.png b/screenshots/statistics.png deleted file mode 100644 index 9feb1c4..0000000 Binary files a/screenshots/statistics.png and /dev/null differ diff --git a/screenshots/tags.png b/screenshots/tags.png deleted file mode 100644 index 7330f08..0000000 Binary files a/screenshots/tags.png and /dev/null differ diff --git a/screenshots/time.png b/screenshots/time.png deleted file mode 100644 index 436dd8e..0000000 Binary files a/screenshots/time.png and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py index 658b7e6..c29e6bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,19 +33,10 @@ def isolate_qsettings(tmp_path_factory): def tmp_db_cfg(tmp_path): from bouquin.db import DBConfig - default_db = tmp_path / "notebook.db" + db_path = tmp_path / "notebook.db" key = "test-secret-key" return DBConfig( - path=default_db, - key=key, - idle_minutes=0, - theme="light", - move_todos=True, - tags=True, - time_log=True, - reminders=True, - locale="en", - font_size=11, + path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True ) diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py deleted file mode 100644 index 8d773e9..0000000 --- a/tests/test_bug_report_dialog.py +++ /dev/null @@ -1,324 +0,0 @@ -import bouquin.bug_report_dialog as bugmod -from bouquin.bug_report_dialog import BugReportDialog -from bouquin import strings -from PySide6.QtWidgets import QMessageBox -from PySide6.QtGui import QTextCursor - - -def test_bug_report_truncates_text_to_max_chars(qtbot): - dlg = BugReportDialog() - qtbot.addWidget(dlg) - dlg.show() - - max_chars = getattr(dlg, "MAX_CHARS", 5000) - - # Make a string longer than the allowed maximum - long_text = "x" * (max_chars + 50) - - # Setting the text should trigger textChanged -> _enforce_max_length - dlg.text_edit.setPlainText(long_text) - - # Let Qt process the signal/slot if needed - qtbot.wait(10) - - current = dlg.text_edit.toPlainText() - assert len(current) == max_chars - assert current == long_text[:max_chars] - - -def test_bug_report_allows_up_to_max_chars_unchanged(qtbot): - dlg = BugReportDialog() - qtbot.addWidget(dlg) - dlg.show() - - max_chars = getattr(dlg, "MAX_CHARS", 5000) - exact_text = "y" * max_chars - - dlg.text_edit.setPlainText(exact_text) - qtbot.wait(10) - - current = dlg.text_edit.toPlainText() - # Should not be trimmed if it's exactly the limit - assert len(current) == max_chars - assert current == exact_text - - -def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch): - dlg = BugReportDialog() - qtbot.addWidget(dlg) - dlg.show() - - # Non-empty message so we don't hit the "empty" warning branch - dlg.text_edit.setPlainText("Hello, something broke.") - qtbot.wait(10) - - # Make version() deterministic - def fake_version(pkg_name): - assert pkg_name == "bouquin" - return "1.2.3" - - monkeypatch.setattr( - bugmod.importlib.metadata, "version", fake_version, raising=True - ) - - # Capture the POST call and fake a 201 Created response - calls = {} - - class DummyResp: - status_code = 201 - - def fake_post(url, json=None, timeout=None): - calls["url"] = url - calls["json"] = json - calls["timeout"] = timeout - return DummyResp() - - monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True) - - # Capture information / critical message boxes - info_called = {} - crit_called = {} - - def fake_info(parent, title, text, *a, **k): - info_called["title"] = title - info_called["text"] = str(text) - return 0 - - def fake_critical(parent, title, text, *a, **k): - crit_called["title"] = title - crit_called["text"] = str(text) - return 0 - - monkeypatch.setattr( - bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True - ) - monkeypatch.setattr( - bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True - ) - - # Don't actually close the dialog in the test; just record that accept() was called - accepted = {} - - def fake_accept(): - accepted["called"] = True - - dlg.accept = fake_accept - - # Call the send logic directly - dlg._send() - - # --- Assertions --------------------------------------------------------- - - # POST was called with the expected URL and JSON payload - assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}" - assert calls["json"]["message"] == "Hello, something broke." - assert calls["json"]["version"] == "1.2.3" - # No attachment fields expected any more - - # Success path: information dialog shown, critical not shown - assert "title" in info_called - assert "text" in info_called - assert crit_called == {} - - # Dialog accepted - assert accepted.get("called") is True - - -def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted( - qtbot, monkeypatch -): - dlg = BugReportDialog() - qtbot.addWidget(dlg) - dlg.show() - - dlg.text_edit.setPlainText("Broken again.") - qtbot.wait(10) - - # Stub version() again - monkeypatch.setattr( - bugmod.importlib.metadata, - "version", - lambda name: "9.9.9", - raising=True, - ) - - # Fake a non-201 response (e.g. 500) - calls = {} - - class DummyResp: - status_code = 500 - - def fake_post(url, json=None, timeout=None): - calls["url"] = url - calls["json"] = json - calls["timeout"] = timeout - return DummyResp() - - monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True) - - info_called = {} - crit_called = {} - - def fake_info(parent, title, text, *a, **k): - info_called["title"] = title - info_called["text"] = str(text) - return 0 - - def fake_critical(parent, title, text, *a, **k): - crit_called["title"] = title - crit_called["text"] = str(text) - return 0 - - monkeypatch.setattr( - bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True - ) - monkeypatch.setattr( - bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True - ) - - accepted = {} - - def fake_accept(): - accepted["called"] = True - - dlg.accept = fake_accept - - dlg._send() - - # POST still called with JSON payload - assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}" - assert calls["json"]["message"] == "Broken again." - assert calls["json"]["version"] == "9.9.9" - - # Failure path: critical dialog shown, information not shown - assert crit_called # non-empty - assert info_called == {} - - # Dialog should NOT be accepted on failure - assert accepted.get("called") is not True - - -def test_bug_report_dialog_text_limit_clamps_cursor(qtbot): - """Test that cursor position is clamped when text exceeds limit.""" - strings.load_strings("en") - dialog = BugReportDialog() - qtbot.addWidget(dialog) - dialog.show() - - # Set text that exceeds MAX_CHARS - max_chars = dialog.MAX_CHARS - long_text = "A" * (max_chars + 100) - - # Set text and move cursor to end - dialog.text_edit.setPlainText(long_text) - dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End) - - # Text should be truncated - assert len(dialog.text_edit.toPlainText()) == max_chars - - # Cursor should be clamped to max position - final_cursor = dialog.text_edit.textCursor() - assert final_cursor.position() <= max_chars - - -def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch): - """Test that sending empty report shows warning.""" - strings.load_strings("en") - dialog = BugReportDialog() - qtbot.addWidget(dialog) - dialog.show() - - # Clear any text - dialog.text_edit.clear() - - warning_shown = {"shown": False} - - def mock_warning(*args): - warning_shown["shown"] = True - - monkeypatch.setattr(QMessageBox, "warning", mock_warning) - - # Try to send empty report - dialog._send() - - assert warning_shown["shown"] - - -def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch): - """Test that sending whitespace-only report shows warning.""" - strings.load_strings("en") - dialog = BugReportDialog() - qtbot.addWidget(dialog) - dialog.show() - - # Set whitespace only - dialog.text_edit.setPlainText(" \n\n \t\t ") - - warning_shown = {"shown": False} - - def mock_warning(*args): - warning_shown["shown"] = True - - monkeypatch.setattr(QMessageBox, "warning", mock_warning) - - dialog._send() - - assert warning_shown["shown"] - - -def test_bug_report_dialog_network_error(qtbot, monkeypatch): - """Test handling network error during send.""" - strings.load_strings("en") - dialog = BugReportDialog() - qtbot.addWidget(dialog) - dialog.show() - - dialog.text_edit.setPlainText("Test bug report") - - # Mock requests.post to raise exception - import requests - - def mock_post(*args, **kwargs): - raise requests.exceptions.ConnectionError("Network error") - - monkeypatch.setattr(requests, "post", mock_post) - - critical_shown = {"shown": False} - - def mock_critical(*args): - critical_shown["shown"] = True - - monkeypatch.setattr(QMessageBox, "critical", mock_critical) - - dialog._send() - - assert critical_shown["shown"] - - -def test_bug_report_dialog_timeout_error(qtbot, monkeypatch): - """Test handling timeout error during send.""" - strings.load_strings("en") - dialog = BugReportDialog() - qtbot.addWidget(dialog) - dialog.show() - - dialog.text_edit.setPlainText("Test bug report") - - # Mock requests.post to raise timeout - import requests - - def mock_post(*args, **kwargs): - raise requests.exceptions.Timeout("Request timed out") - - monkeypatch.setattr(requests, "post", mock_post) - - critical_shown = {"shown": False} - - def mock_critical(*args): - critical_shown["shown"] = True - - monkeypatch.setattr(QMessageBox, "critical", mock_critical) - - dialog._send() - - assert critical_shown["shown"] diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py deleted file mode 100644 index 145e156..0000000 --- a/tests/test_code_highlighter.py +++ /dev/null @@ -1,398 +0,0 @@ -from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata -from PySide6.QtGui import QTextCharFormat, QFont - - -def test_get_language_patterns_python(app): - """Test getting highlighting patterns for Python.""" - patterns = CodeHighlighter.get_language_patterns("python") - - assert len(patterns) > 0 - # Should have comment pattern - assert any("#" in p[0] for p in patterns) - # Should have string patterns - assert any('"' in p[0] for p in patterns) - # Should have keyword patterns - assert any("keyword" == p[1] for p in patterns) - - -def test_get_language_patterns_javascript(app): - """Test getting highlighting patterns for JavaScript.""" - patterns = CodeHighlighter.get_language_patterns("javascript") - - assert len(patterns) > 0 - # Should have // comment pattern - assert any("//" in p[0] for p in patterns) - # Should have /* */ comment pattern (with escaped asterisks in regex) - assert any(r"/\*" in p[0] for p in patterns) - - -def test_get_language_patterns_php(app): - """Test getting highlighting patterns for PHP.""" - patterns = CodeHighlighter.get_language_patterns("php") - - assert len(patterns) > 0 - # Should have # comment pattern - assert any("#" in p[0] for p in patterns) - # Should have // comment pattern - assert any("//" in p[0] for p in patterns) - # Should have /* */ comment pattern (with escaped asterisks in regex) - assert any(r"/\*" in p[0] for p in patterns) - - -def test_get_language_patterns_bash(app): - """Test getting highlighting patterns for Bash.""" - patterns = CodeHighlighter.get_language_patterns("bash") - - assert len(patterns) > 0 - # Should have # comment pattern - assert any("#" in p[0] for p in patterns) - # Should have bash keywords - keyword_patterns = [p for p in patterns if p[1] == "keyword"] - assert len(keyword_patterns) > 0 - - -def test_get_language_patterns_html(app): - """Test getting highlighting patterns for HTML.""" - patterns = CodeHighlighter.get_language_patterns("html") - - assert len(patterns) > 0 - # Should have tag pattern - assert any("tag" == p[1] for p in patterns) - # Should have HTML comment pattern - assert any("" in 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 index 7896c98..7a36dd6 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,9 +1,5 @@ -import pytest import json, csv import datetime as dt -from sqlcipher3 import dbapi2 as sqlite -from bouquin.db import DBManager -from datetime import date, timedelta def _today(): @@ -18,10 +14,6 @@ def _tomorrow(): return (dt.date.today() + dt.timedelta(days=1)).isoformat() -def _days_ago(n): - return (date.today() - timedelta(days=n)).isoformat() - - def _entry(text, i=0): return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo" @@ -65,7 +57,7 @@ def test_dates_with_content_and_search(fresh_db): assert any(d == _tomorrow() for d, _ in hits) -def test_get_all_entries_and_export(fresh_db, tmp_path): +def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path): for i in range(3): d = (dt.date.today() - dt.timedelta(days=i)).isoformat() fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}") @@ -80,6 +72,10 @@ def test_get_all_entries_and_export(fresh_db, tmp_path): fresh_db.export_csv(entries, str(csv_path)) assert csv_path.exists() and list(csv.reader(open(csv_path))) + txt_path = tmp_path / "export.txt" + fresh_db.export_txt(entries, str(txt_path)) + assert txt_path.exists() and txt_path.read_text().strip() + md_path = tmp_path / "export.md" fresh_db.export_markdown(entries, str(md_path)) md_text = md_path.read_text() @@ -97,12 +93,19 @@ def test_get_all_entries_and_export(fresh_db, tmp_path): fresh_db.export_sqlcipher(str(sqlc_path)) assert sqlc_path.exists() and sqlc_path.read_bytes() + for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]: + path.unlink(missing_ok=True) + fresh_db.export_by_extension(str(path)) + assert path.exists() + def test_rekey_and_reopen(fresh_db, tmp_db_cfg): fresh_db.save_new_version(_today(), _entry("secure"), "before rekey") fresh_db.rekey("new-key-123") fresh_db.close() + from bouquin.db import DBManager + tmp_db_cfg.key = "new-key-123" db2 = DBManager(tmp_db_cfg) assert db2.connect() @@ -115,490 +118,10 @@ def test_compact_and_close_dont_crash(fresh_db): fresh_db.close() -def test_connect_integrity_failure(monkeypatch, tmp_db_cfg): - db = DBManager(tmp_db_cfg) - # simulate cursor() ok, but integrity check raising - called = {"ok": False} - - def bad_integrity(self): - called["ok"] = True - raise sqlite.Error("bad cipher") - - monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True) - ok = db.connect() - assert not ok and called["ok"] - assert db.conn is None +import pytest -def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg): - db = DBManager(tmp_db_cfg) - assert db.connect() - - # Monkeypatch connect() on the instance so the reconnect attempt fails - def fail_connect(): - return False - - monkeypatch.setattr(db, "connect", fail_connect, raising=False) - with pytest.raises(sqlite.Error): - db.rekey("newkey") - - -def test_revert_wrong_date_raises(fresh_db): - d1, d2 = "2024-01-01", "2024-01-02" - v1_id, _ = fresh_db.save_new_version(d1, "one", "seed") - fresh_db.save_new_version(d2, "two", "seed") +def test_export_by_extension_unsupported(fresh_db, tmp_path): + p = tmp_path / "export.xyz" with pytest.raises(ValueError): - fresh_db.revert_to_version(d2, version_id=v1_id) - - -def test_compact_error_path(monkeypatch, tmp_db_cfg): - db = DBManager(tmp_db_cfg) - assert db.connect() - - # Replace cursor.execute to raise to hit except branch - class BadCur: - def execute(self, *a, **k): - raise RuntimeError("boom") - - class BadConn: - def cursor(self): - return BadCur() - - db.conn = BadConn() - # Should not raise; just print error - db.compact() - - -class _Cur: - def __init__(self, rows): - self._rows = rows - - def execute(self, *a, **k): - return self - - def fetchall(self): - return list(self._rows) - - -class _Conn: - def __init__(self, rows): - self._rows = rows - - def cursor(self): - return _Cur(self._rows) - - -def test_integrity_check_raises_with_details(tmp_db_cfg): - db = DBManager(tmp_db_cfg) - assert db.connect() - # Force the integrity check to report problems with text details - db.conn = _Conn([("bad page checksum",), (None,)]) - with pytest.raises(sqlite.IntegrityError) as ei: - db._integrity_ok() - # Message should contain the detail string - assert "bad page checksum" in str(ei.value) - - -def test_integrity_check_raises_without_details(tmp_db_cfg): - db = DBManager(tmp_db_cfg) - assert db.connect() - # Force the integrity check to report problems but without textual details - db.conn = _Conn([(None,), (None,)]) - with pytest.raises(sqlite.IntegrityError): - db._integrity_ok() - - -# ============================================================================ -# DB _strip_markdown and _count_words Tests -# ============================================================================ - - -def test_db_strip_markdown_empty_text(fresh_db): - """Test strip_markdown with empty text.""" - result = fresh_db._strip_markdown("") - assert result == "" - - -def test_db_strip_markdown_none_text(fresh_db): - """Test strip_markdown with None.""" - result = fresh_db._strip_markdown(None) - assert result == "" - - -def test_db_strip_markdown_fenced_code_blocks(fresh_db): - """Test stripping fenced code blocks.""" - text = """ -Some text here -```python -def hello(): - print("world") -``` -More text after -""" - result = fresh_db._strip_markdown(text) - assert "def hello" not in result - assert "Some text" in result - assert "More text" in result - - -def test_db_strip_markdown_inline_code(fresh_db): - """Test stripping inline code.""" - text = "Here is some `inline code` in text" - result = fresh_db._strip_markdown(text) - assert "`" not in result - assert "inline code" not in result - assert "Here is some" in result - assert "in text" in result - - -def test_db_strip_markdown_links(fresh_db): - """Test converting markdown links to plain text.""" - text = "Check out [this link](https://example.com) for more info" - result = fresh_db._strip_markdown(text) - assert "this link" in result - assert "https://example.com" not in result - assert "[" not in result - assert "]" not in result - - -def test_db_strip_markdown_emphasis_and_headers(fresh_db): - """Test stripping emphasis markers and headers.""" - text = """ -# Header 1 -## Header 2 -**bold text** and *italic text* -> blockquote -_underline_ -""" - result = fresh_db._strip_markdown(text) - assert "#" not in result - assert "*" not in result - assert "_" not in result - assert ">" not in result - assert "bold text" in result - assert "italic text" in result - - -def test_db_strip_markdown_html_tags(fresh_db): - """Test stripping HTML tags.""" - text = "Some 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 + fresh_db.export_by_extension(str(p)) diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py index c0ab938..3dd3731 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -1,10 +1,8 @@ import pytest from PySide6.QtGui import QTextCursor -from PySide6.QtWidgets import QTextEdit, QWidget from bouquin.markdown_editor import MarkdownEditor from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.find_bar import FindBar @pytest.fixture @@ -16,6 +14,10 @@ def editor(app, qtbot): return ed +from bouquin.find_bar import FindBar + + +@pytest.mark.gui def test_findbar_basic_navigation(qtbot, editor): editor.from_markdown("alpha\nbeta\nalpha\nGamma\n") editor.moveCursor(QTextCursor.Start) @@ -40,6 +42,7 @@ def test_findbar_basic_navigation(qtbot, editor): def test_show_bar_seeds_selection(qtbot, editor): + from PySide6.QtGui import QTextCursor editor.from_markdown("alpha beta") c = editor.textCursor() @@ -52,120 +55,3 @@ def test_show_bar_seeds_selection(qtbot, editor): fb.show_bar() assert fb.edit.text().lower() == "alpha" fb.hide_bar() - - -def test_show_bar_no_editor(qtbot, app): - fb = FindBar(lambda: None) - qtbot.addWidget(fb) - fb.show_bar() # should early return without crashing and not become visible - assert not fb.isVisible() - - -def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor): - editor.from_markdown("alpha\n\nbeta") - c = editor.textCursor() - c.movePosition(QTextCursor.Start) - # Select across the paragraph separator U+2029 equivalent – select more than one block - c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - editor.setTextCursor(c) - fb = FindBar(lambda: editor, parent=editor) - qtbot.addWidget(fb) - fb.show_bar() - assert fb.edit.text() == "" # should not seed with multi-paragraph - fb.hide_bar() - - -def test_find_wraps_and_bumps_caret(qtbot, editor): - editor.from_markdown("alpha alpha alpha") - fb = FindBar(lambda: editor, parent=editor) - qtbot.addWidget(fb) - fb.edit.setText("alpha") - - # Select the first occurrence so caret bumping path triggers - c = editor.textCursor() - c.movePosition(QTextCursor.Start) - c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor) - editor.setTextCursor(c) - - fb.find_next() # should bump to after current selection then find next - sel = editor.textCursor().selectedText() - assert sel.lower() == "alpha" - - # Force wrap to start by moving cursor to end then searching next - c = editor.textCursor() - c.movePosition(QTextCursor.End) - editor.setTextCursor(c) - fb.find_next() # triggers wrap-to-start path - assert editor.textCursor().hasSelection() - - -def test_update_highlight_clear_when_empty(qtbot, editor): - editor.from_markdown("find me find me") - fb = FindBar(lambda: editor, parent=editor) - qtbot.addWidget(fb) - fb.edit.setText("find") - fb._update_highlight() - assert editor.extraSelections() # some highlights present - - fb.edit.setText("") - fb._update_highlight() # should clear - assert not editor.extraSelections() - - -def test_maybe_hide_and_wrap_prev(qtbot, editor): - editor.setPlainText("a a a") - fb = FindBar(editor=editor, shortcut_parent=editor) - qtbot.addWidget(editor) - qtbot.addWidget(fb) - editor.show() - fb.show() - - fb.edit.setText("a") - fb._update_highlight() - - assert fb.isVisible() - fb._maybe_hide() - assert not fb.isVisible() - - fb.show_bar() - c = editor.textCursor() - c.movePosition(QTextCursor.Start) - editor.setTextCursor(c) - fb.find_prev() - - -def _make_fb(editor, qtbot): - """Create a FindBar with a live parent kept until teardown.""" - parent = QWidget() - qtbot.addWidget(parent) - fb = FindBar(editor=editor, parent=parent) - qtbot.addWidget(fb) - parent.show() - fb.show() - return fb, parent - - -def test_find_next_early_returns_no_editor(qtbot): - # No editor: should early return and not crash - fb, _parent = _make_fb(editor=None, qtbot=qtbot) - fb.find_next() - - -def test_find_next_early_returns_empty_text(qtbot): - ed = QTextEdit() - fb, _parent = _make_fb(editor=ed, qtbot=qtbot) - fb.edit.setText("") # empty -> early return - fb.find_next() - - -def test_find_prev_early_returns_empty_text(qtbot): - ed = QTextEdit() - fb, _parent = _make_fb(editor=ed, qtbot=qtbot) - fb.edit.setText("") # empty -> early return - fb.find_prev() - - -def test_update_highlight_early_returns_no_editor(qtbot): - fb, _parent = _make_fb(editor=None, qtbot=qtbot) - fb.edit.setText("abc") - fb._update_highlight() # should return without error diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py index da97a5a..ea24c5a 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -1,5 +1,5 @@ -from PySide6.QtWidgets import QWidget, QMessageBox, QApplication -from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QWidget +from PySide6.QtCore import Qt from bouquin.history_dialog import HistoryDialog @@ -17,295 +17,3 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db): dlg.list.setCurrentRow(1) qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton) assert fresh_db.get_entry(d) == "v1" - - -def test_history_dialog_no_selection_clears(qtbot, fresh_db): - d = "2001-01-01" - fresh_db.save_new_version(d, "v1", "first") - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Clear selection (no current item) and call slot - dlg.list.setCurrentItem(None) - dlg._on_select() - assert dlg.preview.toPlainText() == "" - assert dlg.diff.toPlainText() == "" - assert not dlg.btn_revert.isEnabled() - - -def test_history_dialog_revert_same_version_noop(qtbot, fresh_db): - d = "2001-01-01" - # Only one version; that's the current - vid, _ = fresh_db.save_new_version(d, "seed", "note") - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Pick the only item (current) - dlg.list.setCurrentRow(0) - # Clicking revert should simply return (no change) - before = fresh_db.get_entry(d) - dlg._revert() - after = fresh_db.get_entry(d) - assert before == after - - -def test_history_dialog_revert_error_shows_message(qtbot, fresh_db): - d = "2001-01-02" - fresh_db.save_new_version(d, "v1", "first") - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Select the row - dlg.list.setCurrentRow(0) - - # Monkeypatch db to raise inside revert_to_version to hit except path - def boom(date_iso, version_id): - raise RuntimeError("nope") - - dlg._db.revert_to_version = boom - - # Auto-accept any QMessageBox that appears - def _pump(): - for m in QMessageBox.instances(): - m.accept() - - t = QTimer() - t.setInterval(10) - t.timeout.connect(_pump) - t.start() - try: - dlg._revert() - finally: - t.stop() - - -def test_revert_returns_when_no_item_selected(qtbot, fresh_db): - d = "2000-01-01" - fresh_db.save_new_version(d, "v1", "first") - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - # No selection at all -> early return - dlg.list.clearSelection() - dlg._revert() # should not raise - - -def test_revert_returns_when_current_selected(qtbot, fresh_db): - d = "2000-01-02" - fresh_db.save_new_version(d, "v1", "first") - # Create a second version so there is a 'current' - fresh_db.save_new_version(d, "v2", "second") - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - # Select the current item -> early return - for i in range(dlg.list.count()): - item = dlg.list.item(i) - if item.data(Qt.UserRole) == dlg._current_id: - dlg.list.setCurrentItem(item) - break - dlg._revert() # no-op - - -def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch): - """ - Trigger the exception path in _revert() and auto-accept the modal - QMessageBox that HistoryDialog pops so the test doesn't hang. - """ - d = "2000-01-03" - fresh_db.save_new_version(d, "v1", "first") - fresh_db.save_new_version(d, "v2", "second") - - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Select a non-current item - for i in range(dlg.list.count()): - item = dlg.list.item(i) - if item.data(Qt.UserRole) != dlg._current_id: - dlg.list.setCurrentItem(item) - break - - # Make revert raise to hit the except/critical message path. - def boom(*_a, **_k): - raise RuntimeError("nope") - - monkeypatch.setattr(dlg._db, "revert_to_version", boom) - - # Prepare a small helper that keeps trying to close an active modal box, - # but gives up after a bounded number of attempts. - def make_closer(max_tries=50, interval_ms=10): - tries = {"n": 0} - - def closer(): - tries["n"] += 1 - w = QApplication.activeModalWidget() - if isinstance(w, QMessageBox): - # Prefer clicking the OK button if present; otherwise accept(). - ok = w.button(QMessageBox.Ok) - if ok is not None: - ok.click() - else: - w.accept() - elif tries["n"] < max_tries: - QTimer.singleShot(interval_ms, closer) - - return closer - - # Schedule auto-close right before we trigger the modal dialog. - QTimer.singleShot(0, make_closer()) - - # Should show the critical box, which our timer will accept; _revert returns. - dlg._revert() - - -def test_delete_version_from_history(qtbot, fresh_db): - """Test deleting a version through the history dialog.""" - d = "2001-01-01" - - # Create multiple versions - vid1, _ = fresh_db.save_new_version(d, "v1", "first") - vid2, _ = fresh_db.save_new_version(d, "v2", "second") - vid3, _ = fresh_db.save_new_version(d, "v3", "third") - - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Verify we have 3 versions - assert dlg.list.count() == 3 - - # Select the first version (oldest, not current) - dlg.list.setCurrentRow(2) # Last row is oldest version - - # Call _delete - dlg._delete() - - # Verify the version was deleted - assert dlg.list.count() == 2 - - # Verify from DB - versions = fresh_db.list_versions(d) - assert len(versions) == 2 - - -def test_delete_current_version_returns_early(qtbot, fresh_db): - """Test that deleting the current version returns early without deleting.""" - d = "2001-01-02" - - # Create versions - vid1, _ = fresh_db.save_new_version(d, "v1", "first") - vid2, _ = fresh_db.save_new_version(d, "v2", "second") - - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Find and select the current version - for i in range(dlg.list.count()): - item = dlg.list.item(i) - if item.data(Qt.UserRole) == dlg._current_id: - dlg.list.setCurrentItem(item) - break - - # Try to delete - should return early - dlg._delete() - - # Verify nothing was deleted - versions = fresh_db.list_versions(d) - assert len(versions) == 2 - - -def test_delete_version_with_error(qtbot, fresh_db, monkeypatch): - """Test that delete version error shows a message box.""" - d = "2001-01-03" - - # Create versions - vid1, _ = fresh_db.save_new_version(d, "v1", "first") - vid2, _ = fresh_db.save_new_version(d, "v2", "second") - - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Select a non-current version - for i in range(dlg.list.count()): - item = dlg.list.item(i) - if item.data(Qt.UserRole) != dlg._current_id: - dlg.list.setCurrentItem(item) - break - - # Make delete_version raise an error - def boom(*args, **kwargs): - raise RuntimeError("Delete failed") - - monkeypatch.setattr(dlg._db, "delete_version", boom) - - # Set up auto-closer for message box - def make_closer(max_tries=50, interval_ms=10): - tries = {"n": 0} - - def closer(): - tries["n"] += 1 - w = QApplication.activeModalWidget() - if isinstance(w, QMessageBox): - ok = w.button(QMessageBox.Ok) - if ok is not None: - ok.click() - else: - w.accept() - elif tries["n"] < max_tries: - QTimer.singleShot(interval_ms, closer) - - return closer - - QTimer.singleShot(0, make_closer()) - - # Call delete - should show error message - dlg._delete() - - -def test_delete_multiple_versions(qtbot, fresh_db): - """Test deleting multiple versions at once.""" - d = "2001-01-04" - - # Create multiple versions - vid1, _ = fresh_db.save_new_version(d, "v1", "first") - vid2, _ = fresh_db.save_new_version(d, "v2", "second") - vid3, _ = fresh_db.save_new_version(d, "v3", "third") - vid4, _ = fresh_db.save_new_version(d, "v4", "fourth") - - w = QWidget() - dlg = HistoryDialog(fresh_db, d, parent=w) - qtbot.addWidget(dlg) - dlg.show() - - # Select multiple non-current items - selected_count = 0 - for i in range(dlg.list.count()): - item = dlg.list.item(i) - if item.data(Qt.UserRole) != dlg._current_id: - item.setSelected(True) - selected_count += 1 - if selected_count >= 2: # Select 2 items - break - - # Delete them - dlg._delete() - - # Verify versions were deleted (should have current + 1 remaining) - versions = fresh_db.list_versions(d) - assert len(versions) == 2 # Current + 1 that wasn't deleted diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index f044fac..07a0044 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -1,205 +1,9 @@ from bouquin.key_prompt import KeyPrompt -from PySide6.QtCore import QTimer -from PySide6.QtWidgets import QFileDialog, QLineEdit - def test_key_prompt_roundtrip(qtbot): kp = KeyPrompt() qtbot.addWidget(kp) kp.show() - kp.key_entry.setText("swordfish") + kp.edit.setText("swordfish") assert kp.key() == "swordfish" - - -def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch): - """Test KeyPrompt with DB path selection - covers lines 57-67""" - test_db = tmp_path / "test.db" - test_db.touch() - - # Create prompt with show_db_change=True - prompt = KeyPrompt(show_db_change=True) - qtbot.addWidget(prompt) - - # Mock the file dialog to return a file - def mock_get_open_filename(*args, **kwargs): - return str(test_db), "SQLCipher DB (*.db)" - - monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) - - # Simulate clicking the browse button - # Find the browse button by looking through the widget's children - browse_btn = None - for child in prompt.findChildren(object): - if hasattr(child, "clicked") and hasattr(child, "text"): - if ( - "select" in str(child.text()).lower() - or "browse" in str(child.text()).lower() - ): - browse_btn = child - break - - if browse_btn: - browse_btn.click() - qtbot.wait(50) - - # Verify the path was set - assert prompt.path_edit is not None - assert str(test_db) in prompt.path_edit.text() - - -def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch): - """Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition""" - # Create prompt with show_db_change=True - prompt = KeyPrompt(show_db_change=True) - qtbot.addWidget(prompt) - - # Mock the file dialog to return empty string (user cancelled) - def mock_get_open_filename(*args, **kwargs): - return "", "" - - monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) - - # Store original path text - original_text = prompt.path_edit.text() if prompt.path_edit else "" - - # Simulate clicking the browse button - browse_btn = None - for child in prompt.findChildren(object): - if hasattr(child, "clicked") and hasattr(child, "text"): - if ( - "select" in str(child.text()).lower() - or "browse" in str(child.text()).lower() - ): - browse_btn = child - break - - if browse_btn: - browse_btn.click() - qtbot.wait(50) - - # Path should not have changed since no file was selected - if prompt.path_edit: - assert prompt.path_edit.text() == original_text - - -def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path): - """Test KeyPrompt with existing DB path provided""" - test_db = tmp_path / "existing.db" - test_db.touch() - - prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db) - qtbot.addWidget(prompt) - - # Verify the path is pre-filled - assert prompt.path_edit is not None - assert str(test_db) in prompt.path_edit.text() - - -def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): - """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57""" - prompt = KeyPrompt(show_db_change=True, initial_db_path=None) - qtbot.addWidget(prompt) - - # Path edit should exist but be empty - assert prompt.path_edit is not None - assert prompt.path_edit.text() == "" - - -def test_key_prompt_accept_with_valid_key(qtbot, app): - """Test accepting prompt with valid key""" - prompt = KeyPrompt() - qtbot.addWidget(prompt) - - # Enter a key - prompt.key_entry.setText("test-key-123") - - # Accept - QTimer.singleShot(0, prompt.accept) - qtbot.wait(50) - - assert prompt.key_entry.text() == "test-key-123" - - -def test_key_prompt_without_db_change(qtbot, app): - """Test KeyPrompt without show_db_change""" - prompt = KeyPrompt(show_db_change=False) - qtbot.addWidget(prompt) - - # Path edit should not exist - assert prompt.path_edit is None - - -def test_key_prompt_password_visibility(qtbot, app): - """Test password entry mode""" - prompt = KeyPrompt() - qtbot.addWidget(prompt) - - # Initially should be password mode - assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password - - # Enter some text - prompt.key_entry.setText("secret") - - # The text should be obscured - assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password - - -def test_key_prompt_key_method(qtbot, app): - """Test the key() method returns entered text""" - prompt = KeyPrompt() - qtbot.addWidget(prompt) - - prompt.key_entry.setText("my-secret-key") - - assert prompt.key() == "my-secret-key" - - -def test_key_prompt_db_path_method(qtbot, app, tmp_path): - """Test the db_path() method returns selected path""" - test_db = tmp_path / "test.db" - test_db.touch() - - prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db) - qtbot.addWidget(prompt) - - # Should return the db_path - assert prompt.db_path() == test_db - - -def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): - """Test browsing when initial_db_path is set - covers line 57 with non-None path""" - initial_db = tmp_path / "initial.db" - initial_db.touch() - - new_db = tmp_path / "new.db" - new_db.touch() - - prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db) - qtbot.addWidget(prompt) - - # Mock the file dialog to return a different file - def mock_get_open_filename(*args, **kwargs): - # Verify that start_dir was passed correctly (line 57) - return str(new_db), "SQLCipher DB (*.db)" - - monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) - - # Find and click browse button - browse_btn = None - for child in prompt.findChildren(object): - if hasattr(child, "clicked") and hasattr(child, "text"): - if ( - "select" in str(child.text()).lower() - or "browse" in str(child.text()).lower() - ): - browse_btn = child - break - - if browse_btn: - browse_btn.click() - qtbot.wait(50) - - # Verify new path was set - assert str(new_db) in prompt.path_edit.text() - assert prompt.db_path() == new_db diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py index 05de5f9..7d3ebe8 100644 --- a/tests/test_lock_overlay.py +++ b/tests/test_lock_overlay.py @@ -1,15 +1,16 @@ +import pytest from PySide6.QtCore import QEvent from PySide6.QtWidgets import QWidget from bouquin.lock_overlay import LockOverlay -from bouquin.theme import ThemeManager, ThemeConfig, Theme -def test_lock_overlay_reacts_to_theme(app, qtbot): +@pytest.mark.gui +def test_lock_overlay_reacts_to_theme(qtbot): host = QWidget() qtbot.addWidget(host) host.show() - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - ol = LockOverlay(host, on_unlock=lambda: None, themes=themes) + + ol = LockOverlay(host, on_unlock=lambda: None) qtbot.addWidget(ol) ol.show() diff --git a/tests/test_main.py b/tests/test_main.py index 2a357fb..cadfaa2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,4 @@ import importlib -import runpy -import pytest def test_main_module_has_main(): @@ -11,87 +9,3 @@ def test_main_module_has_main(): def test_dunder_main_imports_main(): m = importlib.import_module("bouquin.__main__") assert hasattr(m, "main") - - -def test_dunder_main_calls_main(monkeypatch): - called = {"ok": False} - - def fake_main(): - called["ok"] = True - - # Replace real main with a stub to avoid launching Qt event loop - monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") - # Ensure that when __main__ imports from .main it gets our stub - import bouquin.main as real_main - - monkeypatch.setattr(real_main, "main", fake_main, raising=True) - # Execute the module as a script - runpy.run_module("bouquin.__main__", run_name="__main__") - assert called["ok"] - - -def test_main_creates_and_shows(monkeypatch): - # Create a fake QApplication with the minimal API - class FakeApp: - def __init__(self, argv): - self.ok = True - - def setApplicationName(self, *_): - pass - - def setOrganizationName(self, *_): - pass - - def setWindowIcon(self, *_): - pass - - def exec(self): - return 0 - - class FakeWin: - def __init__(self, themes=None): - self.shown = False - - def show(self): - self.shown = True - - class FakeSettings: - def value(self, k, default=None): - return "light" if k == "ui/theme" else default - - # Patch imports inside bouquin.main - import bouquin.main as m - - monkeypatch.setattr(m, "QApplication", FakeApp, raising=True) - monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True) - - # Theme classes - class FakeTM: - def __init__(self, app, cfg): - pass - - def apply(self, theme): - pass - - class FakeTheme: - def __init__(self, s): - pass - - class FakeCfg: - def __init__(self, theme): - self.theme = theme - - monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True) - monkeypatch.setattr(m, "Theme", FakeTheme, raising=True) - monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True) - - # get_settings() used inside main() - def fake_get_settings(): - return FakeSettings() - - monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True) - - # Run - with pytest.raises(SystemExit) as e: - m.main() - assert e.value.code == 0 diff --git a/tests/test_main_window.py b/tests/test_main_window.py index bfe0972..3942566 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,36 +1,22 @@ import pytest -import importlib.metadata +from PySide6.QtCore import QDate -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 +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication +@pytest.mark.gui 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/path", 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) @@ -56,7 +42,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def _auto_accept_keyprompt(): for wdg in QApplication.topLevelWidgets(): if isinstance(wdg, KeyPrompt): - wdg.key_entry.setText(tmp_db_cfg.key) + wdg.edit.setText(tmp_db_cfg.key) wdg.accept() w._enter_lock() @@ -66,8 +52,12 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): + from PySide6.QtCore import QDate + from bouquin.theme import ThemeManager, ThemeConfig, Theme + from bouquin.settings import get_settings + s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/move_todos", True) s.setValue("ui/theme", "light") @@ -76,2373 +66,14 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + from bouquin.main_window import MainWindow w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() - w._load_unchecked_todos() + w._load_yesterday_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_markdown_editor.py b/tests/test_markdown_editor.py index cc02ad8..25cae12 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,64 +1,19 @@ -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 PySide6.QtGui import QImage, QColor 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) @@ -96,2343 +51,13 @@ def test_insert_image_from_path(editor, tmp_path): 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 + # Images are saved as base64 data URIs in markdown + assert "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") +def test_apply_code_inline(editor): + editor.from_markdown("alpha beta") 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() + assert ("`" in md) or ("```" in md) diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py deleted file mode 100644 index 9d34a4f..0000000 --- a/tests/test_pomodoro_timer.py +++ /dev/null @@ -1,354 +0,0 @@ -from unittest.mock import Mock, patch -from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager - - -def test_pomodoro_timer_init(qtbot, app, fresh_db): - """Test PomodoroTimer initialization.""" - task_text = "Write unit tests" - timer = PomodoroTimer(task_text) - qtbot.addWidget(timer) - - assert timer._task_text == task_text - assert timer._elapsed_seconds == 0 - assert timer._running is False - assert timer.time_label.text() == "00:00:00" - assert timer.stop_btn.isEnabled() is False - - -def test_pomodoro_timer_start(qtbot, app): - """Test starting the timer.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - timer._toggle_timer() - - assert timer._running is True - assert timer.stop_btn.isEnabled() is True - - -def test_pomodoro_timer_pause(qtbot, app): - """Test pausing the timer.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Start the timer - timer._toggle_timer() - assert timer._running is True - - # Pause the timer - timer._toggle_timer() - assert timer._running is False - - -def test_pomodoro_timer_resume(qtbot, app): - """Test resuming the timer after pause.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Start, pause, then resume - timer._toggle_timer() # Start - timer._toggle_timer() # Pause - timer._toggle_timer() # Resume - - assert timer._running is True - - -def test_pomodoro_timer_tick(qtbot, app): - """Test timer tick increments elapsed time.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - initial_time = timer._elapsed_seconds - timer._tick() - - assert timer._elapsed_seconds == initial_time + 1 - - -def test_pomodoro_timer_display_update(qtbot, app): - """Test display updates with various elapsed times.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Test 0 seconds - timer._elapsed_seconds = 0 - timer._update_display() - assert timer.time_label.text() == "00:00:00" - - # Test 65 seconds (1 min 5 sec) - timer._elapsed_seconds = 65 - timer._update_display() - assert timer.time_label.text() == "00:01:05" - - # Test 3665 seconds (1 hour 1 min 5 sec) - timer._elapsed_seconds = 3665 - timer._update_display() - assert timer.time_label.text() == "01:01:05" - - # Test 3600 seconds (1 hour exactly) - timer._elapsed_seconds = 3600 - timer._update_display() - assert timer.time_label.text() == "01:00:00" - - -def test_pomodoro_timer_stop_and_log_running(qtbot, app): - """Test stopping the timer while it's running.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Start the timer - timer._toggle_timer() - timer._elapsed_seconds = 100 - - # Connect a mock to the signal - signal_received = [] - timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) - - timer._stop_and_log() - - assert timer._running is False - assert len(signal_received) == 1 - assert signal_received[0][0] == 100 # elapsed seconds - assert signal_received[0][1] == "Test task" - - -def test_pomodoro_timer_stop_and_log_paused(qtbot, app): - """Test stopping the timer when it's paused.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - timer._elapsed_seconds = 50 - - signal_received = [] - timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) - - timer._stop_and_log() - - assert len(signal_received) == 1 - assert signal_received[0][0] == 50 - - -def test_pomodoro_timer_multiple_ticks(qtbot, app): - """Test multiple timer ticks.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - for i in range(10): - timer._tick() - - assert timer._elapsed_seconds == 10 - assert "00:00:10" in timer.time_label.text() - - -def test_pomodoro_timer_modal_state(qtbot, app): - """Test that timer is non-modal.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - assert timer.isModal() is False - - -def test_pomodoro_timer_window_title(qtbot, app): - """Test timer window title.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Window title should contain some reference to timer/pomodoro - assert len(timer.windowTitle()) > 0 - - -def test_pomodoro_manager_init(app, fresh_db): - """Test PomodoroManager initialization.""" - parent = Mock() - manager = PomodoroManager(fresh_db, parent) - - assert manager._db is fresh_db - assert manager._parent is parent - assert manager._active_timer is None - - -def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): - """Test starting a timer through the manager.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() - qtbot.addWidget(parent) - manager = PomodoroManager(fresh_db, parent) - - line_text = "Important task" - date_iso = "2024-01-15" - - manager.start_timer_for_line(line_text, date_iso) - - assert manager._active_timer is not None - assert manager._active_timer._task_text == line_text - qtbot.addWidget(manager._active_timer) - - -def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): - """Test that starting a new timer closes the previous one.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() - qtbot.addWidget(parent) - manager = PomodoroManager(fresh_db, parent) - - # Start first timer - manager.start_timer_for_line("Task 1", "2024-01-15") - first_timer = manager._active_timer - qtbot.addWidget(first_timer) - first_timer.show() - - # Start second timer - manager.start_timer_for_line("Task 2", "2024-01-16") - second_timer = manager._active_timer - qtbot.addWidget(second_timer) - - assert first_timer is not second_timer - assert second_timer._task_text == "Task 2" - - -def test_pomodoro_manager_on_timer_stopped_minimum_hours( - qtbot, app, fresh_db, monkeypatch -): - """Test that timer stopped with very short time logs minimum hours.""" - parent = Mock() - manager = PomodoroManager(fresh_db, parent) - - # Mock TimeLogDialog to avoid actually showing it - mock_dialog = Mock() - mock_dialog.hours_spin = Mock() - mock_dialog.note = Mock() - mock_dialog.exec = Mock() - - with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): - manager._on_timer_stopped(10, "Quick task", "2024-01-15") - - # Should set minimum of 0.25 hours - mock_dialog.hours_spin.setValue.assert_called_once() - hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] - assert hours_set >= 0.25 - - -def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): - """Test that elapsed time is properly rounded to decimal hours.""" - parent = Mock() - manager = PomodoroManager(fresh_db, parent) - - mock_dialog = Mock() - mock_dialog.hours_spin = Mock() - mock_dialog.note = Mock() - mock_dialog.exec = Mock() - - with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): - # Test with 1800 seconds (30 minutes) - manager._on_timer_stopped(1800, "Task", "2024-01-15") - - mock_dialog.hours_spin.setValue.assert_called_once() - hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] - # Should round up and be a multiple of 0.25 - assert hours_set > 0 - assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25 - - -def test_pomodoro_manager_on_timer_stopped_prefills_note( - qtbot, app, fresh_db, monkeypatch -): - """Test that timer stopped pre-fills the note in time log dialog.""" - parent = Mock() - manager = PomodoroManager(fresh_db, parent) - - mock_dialog = Mock() - mock_dialog.hours_spin = Mock() - mock_dialog.note = Mock() - mock_dialog.exec = Mock() - - task_text = "Write documentation" - - with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): - manager._on_timer_stopped(3600, task_text, "2024-01-15") - - mock_dialog.note.setText.assert_called_once_with(task_text) - - -def test_pomodoro_manager_timer_stopped_signal_connection( - qtbot, app, fresh_db, monkeypatch -): - """Test that timer stopped signal is properly connected.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() - qtbot.addWidget(parent) - manager = PomodoroManager(fresh_db, parent) - - # Mock TimeLogDialog - mock_dialog = Mock() - mock_dialog.hours_spin = Mock() - mock_dialog.note = Mock() - mock_dialog.exec = Mock() - - with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): - manager.start_timer_for_line("Task", "2024-01-15") - timer = manager._active_timer - qtbot.addWidget(timer) - - # Simulate timer stopped - timer._elapsed_seconds = 1000 - timer._stop_and_log() - - # TimeLogDialog should have been created - assert mock_dialog.exec.called - - -def test_pomodoro_timer_accepts_parent(qtbot, app): - """Test that timer accepts a parent widget.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() - qtbot.addWidget(parent) - timer = PomodoroTimer("Task", parent) - qtbot.addWidget(timer) - - assert timer.parent() is parent - - -def test_pomodoro_manager_no_active_timer_initially(app, fresh_db): - """Test that manager starts with no active timer.""" - parent = Mock() - manager = PomodoroManager(fresh_db, parent) - - assert manager._active_timer is None - - -def test_pomodoro_timer_start_stop_cycle(qtbot, app): - """Test a complete start-stop cycle.""" - timer = PomodoroTimer("Complete cycle") - qtbot.addWidget(timer) - - signal_received = [] - timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) - - # Start - timer._toggle_timer() - assert timer._running is True - - # Simulate some ticks - for _ in range(5): - timer._tick() - - # Stop - timer._stop_and_log() - assert timer._running is False - assert len(signal_received) == 1 - assert signal_received[0][0] == 5 - - -def test_pomodoro_timer_long_elapsed_time(qtbot, app): - """Test display with very long elapsed time.""" - timer = PomodoroTimer("Long task") - qtbot.addWidget(timer) - - # Set to 2 hours, 34 minutes, 56 seconds - timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56 - timer._update_display() - - assert timer.time_label.text() == "02:34:56" diff --git a/tests/test_reminders.py b/tests/test_reminders.py deleted file mode 100644 index c003d86..0000000 --- a/tests/test_reminders.py +++ /dev/null @@ -1,801 +0,0 @@ -from unittest.mock import patch -from bouquin.reminders import ( - Reminder, - ReminderType, - ReminderDialog, - UpcomingRemindersWidget, - ManageRemindersDialog, -) -from PySide6.QtCore import QDate, QTime -from PySide6.QtWidgets import QDialog, QMessageBox, QWidget - -from datetime import date, timedelta - - -def test_reminder_type_enum(app): - """Test ReminderType enum values.""" - assert ReminderType.ONCE is not None - assert ReminderType.DAILY is not None - assert ReminderType.WEEKDAYS is not None - assert ReminderType.WEEKLY is not None - - -def test_reminder_dataclass_creation(app): - """Test creating a Reminder instance.""" - reminder = Reminder( - id=1, - text="Test reminder", - time_str="10:30", - reminder_type=ReminderType.DAILY, - weekday=None, - active=True, - date_iso=None, - ) - - assert reminder.id == 1 - assert reminder.text == "Test reminder" - assert reminder.time_str == "10:30" - assert reminder.reminder_type == ReminderType.DAILY - assert reminder.active is True - - -def test_reminder_dialog_init_new(qtbot, app, fresh_db): - """Test ReminderDialog initialization for new reminder.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - assert dialog._db is fresh_db - assert dialog._reminder is None - assert dialog.text_edit.text() == "" - - -def test_reminder_dialog_init_existing(qtbot, app, fresh_db): - """Test ReminderDialog initialization with existing reminder.""" - reminder = Reminder( - id=1, - text="Existing reminder", - time_str="14:30", - reminder_type=ReminderType.WEEKLY, - weekday=2, - active=True, - ) - - dialog = ReminderDialog(fresh_db, reminder=reminder) - qtbot.addWidget(dialog) - - assert dialog.text_edit.text() == "Existing reminder" - assert dialog.time_edit.time().hour() == 14 - assert dialog.time_edit.time().minute() == 30 - - -def test_reminder_dialog_type_changed(qtbot, app, fresh_db): - """Test that weekday combo visibility changes with type.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - dialog.show() # Show the dialog so child widgets can be visible - - # Find weekly type in combo - for i in range(dialog.type_combo.count()): - if dialog.type_combo.itemData(i) == ReminderType.WEEKLY: - dialog.type_combo.setCurrentIndex(i) - break - - qtbot.wait(10) # Wait for Qt event processing - assert dialog.weekday_combo.isVisible() is True - - # Switch to daily - for i in range(dialog.type_combo.count()): - if dialog.type_combo.itemData(i) == ReminderType.DAILY: - dialog.type_combo.setCurrentIndex(i) - break - - qtbot.wait(10) # Wait for Qt event processing - assert dialog.weekday_combo.isVisible() is False - - -def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db): - """Test getting reminder with ONCE type.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.text_edit.setText("Test task") - dialog.time_edit.setTime(QTime(10, 30)) - - # Set to ONCE type - for i in range(dialog.type_combo.count()): - if dialog.type_combo.itemData(i) == ReminderType.ONCE: - dialog.type_combo.setCurrentIndex(i) - break - - reminder = dialog.get_reminder() - - assert reminder.text == "Test task" - assert reminder.time_str == "10:30" - assert reminder.reminder_type == ReminderType.ONCE - assert reminder.date_iso is not None - - -def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db): - """Test getting reminder with WEEKLY type.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.text_edit.setText("Weekly meeting") - dialog.time_edit.setTime(QTime(15, 0)) - - # Set to WEEKLY type - for i in range(dialog.type_combo.count()): - if dialog.type_combo.itemData(i) == ReminderType.WEEKLY: - dialog.type_combo.setCurrentIndex(i) - break - - dialog.weekday_combo.setCurrentIndex(1) # Tuesday - - reminder = dialog.get_reminder() - - assert reminder.text == "Weekly meeting" - assert reminder.reminder_type == ReminderType.WEEKLY - assert reminder.weekday == 1 - - -def test_upcoming_reminders_widget_init(qtbot, app, fresh_db): - """Test UpcomingRemindersWidget initialization.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - assert widget._db is fresh_db - assert widget.body.isVisible() is False - - -def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db): - """Test toggling reminder list visibility.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - widget.show() # Show the widget so child widgets can be visible - - # Initially hidden - assert widget.body.isVisible() is False - - # Click toggle - widget.toggle_btn.click() - qtbot.wait(10) # Wait for Qt event processing - - assert widget.body.isVisible() is True - - -def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db): - """Test should_fire_on_date for ONCE type.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - reminder = Reminder( - id=1, - text="Test", - time_str="10:00", - reminder_type=ReminderType.ONCE, - date_iso="2024-01-15", - ) - - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False - - -def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db): - """Test should_fire_on_date for DAILY type.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - reminder = Reminder( - id=1, - text="Test", - time_str="10:00", - reminder_type=ReminderType.DAILY, - ) - - # Should fire every day - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True - - -def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db): - """Test should_fire_on_date for WEEKDAYS type.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - reminder = Reminder( - id=1, - text="Test", - time_str="10:00", - reminder_type=ReminderType.WEEKDAYS, - ) - - # Monday (dayOfWeek = 1) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True - # Friday (dayOfWeek = 5) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True - # Saturday (dayOfWeek = 6) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False - # Sunday (dayOfWeek = 7) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False - - -def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db): - """Test should_fire_on_date for WEEKLY type.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - # Fire on Wednesday (weekday = 2) - reminder = Reminder( - id=1, - text="Test", - time_str="10:00", - reminder_type=ReminderType.WEEKLY, - weekday=2, - ) - - # Wednesday (dayOfWeek = 3, so weekday = 2) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True - # Thursday (dayOfWeek = 4, so weekday = 3) - assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False - - -def test_upcoming_reminders_widget_refresh_no_db(qtbot, app): - """Test refresh with no database connection.""" - widget = UpcomingRemindersWidget(None) - qtbot.addWidget(widget) - - # Should not crash - widget.refresh() - - -def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db): - """Test refresh displays reminders.""" - # Add a reminder to the database - reminder = Reminder( - id=None, - text="Test reminder", - time_str="23:59", # Late time so it's in the future - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - widget.refresh() - - # Should have at least one item (or "No upcoming reminders") - assert widget.reminder_list.count() > 0 - - -def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db): - """Test adding a reminder through the widget.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=None, - text="New reminder", - time_str="10:00", - reminder_type=ReminderType.DAILY, - ) - - widget._add_reminder() - - # Reminder should be saved - reminders = fresh_db.get_all_reminders() - assert len(reminders) > 0 - - -def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db): - """Test editing a reminder through the widget.""" - # Add a reminder first - reminder = Reminder( - id=None, - text="Original", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - widget.refresh() - - # Get the list item - if widget.reminder_list.count() > 0: - item = widget.reminder_list.item(0) - - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - updated = Reminder( - id=1, - text="Updated", - time_str="11:00", - reminder_type=ReminderType.DAILY, - ) - mock_get.return_value = updated - - widget._edit_reminder(item) - - -def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db): - """Test deleting a single selected reminder.""" - # Add a reminder - reminder = Reminder( - id=None, - text="To delete", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - widget.refresh() - - if widget.reminder_list.count() > 0: - widget.reminder_list.setCurrentRow(0) - - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - widget._delete_selected_reminders() - - -def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db): - """Test deleting multiple selected reminders.""" - # Add multiple reminders - for i in range(3): - reminder = Reminder( - id=None, - text=f"Reminder {i}", - time_str="23:59", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - widget.refresh() - - # Select all items - for i in range(widget.reminder_list.count()): - widget.reminder_list.item(i).setSelected(True) - - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - widget._delete_selected_reminders() - - -def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app): - """Test check_reminders with no database.""" - widget = UpcomingRemindersWidget(None) - qtbot.addWidget(widget) - - # Should not crash - widget._check_reminders() - - -def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db): - """Test starting the regular check timer.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - widget._start_regular_timer() - - # Timer should be running - assert widget._check_timer.isActive() - - -def test_manage_reminders_dialog_init(qtbot, app, fresh_db): - """Test ManageRemindersDialog initialization.""" - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - assert dialog._db is fresh_db - assert dialog.table is not None - - -def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db): - """Test loading reminders into the table.""" - # Add some reminders - for i in range(3): - reminder = Reminder( - id=None, - text=f"Reminder {i}", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - assert dialog.table.rowCount() == 3 - - -def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app): - """Test loading reminders with no database.""" - dialog = ManageRemindersDialog(None) - qtbot.addWidget(dialog) - - # Should not crash - dialog._load_reminders() - - -def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db): - """Test adding a reminder through the manage dialog.""" - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - initial_count = dialog.table.rowCount() - - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=None, - text="New", - time_str="10:00", - reminder_type=ReminderType.DAILY, - ) - - dialog._add_reminder() - - # Table should have one more row - assert dialog.table.rowCount() == initial_count + 1 - - -def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db): - """Test editing a reminder through the manage dialog.""" - reminder = Reminder( - id=None, - text="Original", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=1, - text="Updated", - time_str="11:00", - reminder_type=ReminderType.DAILY, - ) - - dialog._edit_reminder(reminder) - - -def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db): - """Test deleting a reminder through the manage dialog.""" - reminder = Reminder( - id=None, - text="To delete", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - saved_reminders = fresh_db.get_all_reminders() - reminder_to_delete = saved_reminders[0] - - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - initial_count = dialog.table.rowCount() - - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - dialog._delete_reminder(reminder_to_delete) - - # Table should have one fewer row - assert dialog.table.rowCount() == initial_count - 1 - - -def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db): - """Test declining to delete a reminder.""" - reminder = Reminder( - id=None, - text="Keep me", - time_str="10:00", - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - saved_reminders = fresh_db.get_all_reminders() - reminder_to_keep = saved_reminders[0] - - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - initial_count = dialog.table.rowCount() - - with patch.object(QMessageBox, "question", return_value=QMessageBox.No): - dialog._delete_reminder(reminder_to_keep) - - # Table should have same number of rows - assert dialog.table.rowCount() == initial_count - - -def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db): - """Test that weekly reminders display the day name.""" - reminder = Reminder( - id=None, - text="Weekly", - time_str="10:00", - reminder_type=ReminderType.WEEKLY, - weekday=2, # Wednesday - active=True, - ) - fresh_db.save_reminder(reminder) - - dialog = ManageRemindersDialog(fresh_db) - qtbot.addWidget(dialog) - - # Check that the type column shows the day - type_item = dialog.table.item(0, 2) - assert "Wed" in type_item.text() - - -def test_reminder_dialog_accept(qtbot, app, fresh_db): - """Test accepting the reminder dialog.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.text_edit.setText("Test") - dialog.accept() - - -def test_reminder_dialog_reject(qtbot, app, fresh_db): - """Test rejecting the reminder dialog.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.reject() - - -def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db): - """Test that reminderTriggered signal is emitted.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - signal_received = [] - widget.reminderTriggered.connect(lambda text: signal_received.append(text)) - - # Manually emit for testing - widget.reminderTriggered.emit("Test reminder") - - assert len(signal_received) == 1 - assert signal_received[0] == "Test reminder" - - -def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db): - """Test that 'No upcoming reminders' message is shown when appropriate.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - widget.refresh() - - # Should show message when no reminders - if widget.reminder_list.count() > 0: - item = widget.reminder_list.item(0) - if "No upcoming" in item.text(): - assert True - - -def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db): - """Test clicking the manage button.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - with patch.object(ManageRemindersDialog, "exec"): - widget._manage_reminders() - - -def test_reminder_dialog_time_format(qtbot, app, fresh_db): - """Test that time is formatted correctly.""" - dialog = ReminderDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.time_edit.setTime(QTime(9, 5)) - reminder = dialog.get_reminder() - - assert reminder.time_str == "09:05" - - -def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db): - """Test that past reminders are not shown in upcoming list.""" - # Create a reminder that's in the past - reminder = Reminder( - id=None, - text="Past reminder", - time_str="00:01", # Very early morning - reminder_type=ReminderType.DAILY, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - # Current time should be past 00:01 - from PySide6.QtCore import QTime - - if QTime.currentTime().hour() > 0: - widget.refresh() - # The past reminder for today should be filtered out - # but tomorrow's occurrence should be shown - - -def test_reminder_with_inactive_status(qtbot, app, fresh_db): - """Test that inactive reminders are not displayed.""" - reminder = Reminder( - id=None, - text="Inactive", - time_str="23:59", - reminder_type=ReminderType.DAILY, - active=False, - ) - fresh_db.save_reminder(reminder) - - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - widget.refresh() - - # Should not show inactive reminder - for i in range(widget.reminder_list.count()): - item = widget.reminder_list.item(i) - assert "Inactive" not in item.text() or "No upcoming" in item.text() - - -def test_reminder_triggers_and_deactivates(qtbot, fresh_db): - """Test that ONCE reminders deactivate after firing.""" - # Add a ONCE reminder for right now - now = QTime.currentTime() - hour = now.hour() - minute = now.minute() - - reminder = Reminder( - id=None, - text="Test once reminder", - reminder_type=ReminderType.ONCE, - time_str=f"{hour:02d}:{minute:02d}", - date_iso=date.today().isoformat(), - active=True, - ) - reminder_id = fresh_db.save_reminder(reminder) - - reminders_widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(reminders_widget) - - # Set up signal spy - triggered_texts = [] - reminders_widget.reminderTriggered.connect( - lambda text: triggered_texts.append(text) - ) - - # Trigger the check - reminders_widget._check_reminders() - - # Verify reminder was triggered - assert len(triggered_texts) > 0 - assert "Test once reminder" in triggered_texts - - # Verify reminder was deactivated - reminders = fresh_db.get_all_reminders() - deactivated = [r for r in reminders if r.id == reminder_id][0] - assert deactivated.active is False - - -def test_reminder_not_active_skipped(qtbot, fresh_db): - """Test that inactive reminders are not triggered.""" - now = QTime.currentTime() - hour = now.hour() - minute = now.minute() - - reminder = Reminder( - id=None, - text="Inactive reminder", - reminder_type=ReminderType.ONCE, - time_str=f"{hour:02d}:{minute:02d}", - date_iso=date.today().isoformat(), - active=False, # Not active - ) - fresh_db.save_reminder(reminder) - - reminders_widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(reminders_widget) - - # Set up signal spy - triggered_texts = [] - reminders_widget.reminderTriggered.connect( - lambda text: triggered_texts.append(text) - ) - - # Trigger the check - reminders_widget._check_reminders() - - # Should not trigger inactive reminder - assert len(triggered_texts) == 0 - - -def test_reminder_not_today_skipped(qtbot, fresh_db): - """Test that reminders not scheduled for today are skipped.""" - now = QTime.currentTime() - hour = now.hour() - minute = now.minute() - - # Schedule for tomorrow - tomorrow = date.today() + timedelta(days=1) - - reminder = Reminder( - id=None, - text="Tomorrow's reminder", - reminder_type=ReminderType.ONCE, - time_str=f"{hour:02d}:{minute:02d}", - date_iso=tomorrow.isoformat(), - active=True, - ) - fresh_db.save_reminder(reminder) - - reminders_widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(reminders_widget) - - # Set up signal spy - triggered_texts = [] - reminders_widget.reminderTriggered.connect( - lambda text: triggered_texts.append(text) - ) - - # Trigger the check - reminders_widget._check_reminders() - - # Should not trigger tomorrow's reminder - assert len(triggered_texts) == 0 - - -def test_reminder_context_menu_no_selection(qtbot, fresh_db): - """Test context menu with no selection returns early.""" - reminders_widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(reminders_widget) - - # Clear selection - reminders_widget.reminder_list.clearSelection() - - # Show context menu - should return early - reminders_widget._show_reminder_context_menu(reminders_widget.reminder_list.pos()) - - -def test_edit_reminder_dialog(qtbot, fresh_db): - """Test editing a reminder through the dialog.""" - reminder = Reminder( - id=None, - text="Original text", - reminder_type=ReminderType.DAILY, - time_str="14:30", - date_iso=None, - active=True, - ) - fresh_db.save_reminder(reminder) - - widget = QWidget() - - # Create edit dialog - reminder_obj = fresh_db.get_all_reminders()[0] - dlg = ReminderDialog(fresh_db, widget, reminder=reminder_obj) - qtbot.addWidget(dlg) - - # Verify fields are populated - assert dlg.text_edit.text() == "Original text" - assert dlg.time_edit.time().toString("HH:mm") == "14:30" diff --git a/tests/test_search.py b/tests/test_search.py index 6f3ab23..d8ceb41 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,5 +1,4 @@ from bouquin.search import Search -from PySide6.QtWidgets import QListWidgetItem def test_search_widget_populates_results(qtbot, fresh_db): @@ -21,80 +20,3 @@ def test_search_widget_populates_results(qtbot, fresh_db): s.search.setText("") qtbot.wait(50) assert s.results.isHidden() - - -def test_open_selected_with_data(qtbot, fresh_db): - s = Search(fresh_db) - qtbot.addWidget(s) - s.show() - - seen = [] - s.openDateRequested.connect(lambda d: seen.append(d)) - it = QListWidgetItem("dummy") - from PySide6.QtCore import Qt - - it.setData(Qt.ItemDataRole.UserRole, "1999-12-31") - s.results.addItem(it) - s._open_selected(it) - assert seen == ["1999-12-31"] - - -def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db): - s = Search(fresh_db) - long = ( - "This is **bold** text with alpha in the middle and some more trailing content." - ) - frag = s._make_html_snippet(long, "alpha", radius=10, maxlen=40) - assert "alpha" in frag - s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check") - - -def test_open_selected_ignores_no_data(qtbot, fresh_db): - s = Search(fresh_db) - qtbot.addWidget(s) - s.show() - - seen = [] - s.openDateRequested.connect(lambda d: seen.append(d)) - it = QListWidgetItem("dummy") - # No UserRole data set -> should not emit - s._open_selected(it) - assert not seen - - -def test_make_html_snippet_variants(qtbot, fresh_db): - s = Search(fresh_db) - qtbot.addWidget(s) - s.show() - - # Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen - src = " ".join(["word"] * 200) - frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30) - assert frag - - # Case: multiple tokens highlighted - src = "Alpha bravo charlie delta echo" - frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50) - assert "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_helpers.py b/tests/test_search_helpers.py new file mode 100644 index 0000000..a4e318b --- /dev/null +++ b/tests/test_search_helpers.py @@ -0,0 +1,11 @@ +from bouquin.search import Search + + +def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db): + s = Search(fresh_db) + long = ( + "This is **bold** text with alpha in the middle and some more trailing content." + ) + frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40) + assert "alpha" in frag + s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check") diff --git a/tests/test_settings.py b/tests/test_settings.py index f272ab2..254af98 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,6 @@ +from pathlib import Path from bouquin.settings import ( + default_db_path, get_settings, load_db_config, save_db_config, @@ -6,26 +8,16 @@ from bouquin.settings import ( from bouquin.db import DBConfig -def _clear_db_settings(): - s = get_settings() - for k in [ - "db/default_db", - "db/path", # legacy key - "db/key", - "ui/idle_minutes", - "ui/theme", - "ui/move_todos", - "ui/tags", - "ui/time_log", - "ui/reminders", - "ui/locale", - "ui/font_size", - ]: - s.remove(k) +def test_default_db_path_returns_writable_path(app, tmp_path): + p = default_db_path() + assert isinstance(p, Path) + p.parent.mkdir(parents=True, exist_ok=True) def test_load_and_save_db_config_roundtrip(app, tmp_path): - _clear_db_settings() + s = get_settings() + for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]: + s.remove(k) cfg = DBConfig( path=tmp_path / "notes.db", @@ -33,11 +25,6 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path): idle_minutes=7, theme="dark", move_todos=True, - tags=True, - time_log=True, - reminders=True, - locale="en", - font_size=11, ) save_db_config(cfg) @@ -47,25 +34,3 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path): assert loaded.idle_minutes == cfg.idle_minutes assert loaded.theme == cfg.theme assert loaded.move_todos == cfg.move_todos - assert loaded.tags == cfg.tags - assert loaded.time_log == cfg.time_log - assert loaded.reminders == cfg.reminders - assert loaded.locale == cfg.locale - assert loaded.font_size == cfg.font_size - - -def test_load_db_config_migrates_legacy_db_path(app, tmp_path): - _clear_db_settings() - s = get_settings() - - legacy_path = tmp_path / "legacy.db" - s.setValue("db/path", str(legacy_path)) - - cfg = load_db_config() - - # Uses the legacy value… - assert cfg.path == legacy_path - - # …but also migrates to the new key and clears the old one. - assert s.value("db/default_db", "", type=str) == str(legacy_path) - assert s.value("db/path", "", type=str) == "" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index ad53951..515e769 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,14 +1,12 @@ -from bouquin.db import DBManager, DBConfig -from bouquin.key_prompt import KeyPrompt -import bouquin.settings_dialog as sd +import pytest from bouquin.settings_dialog import SettingsDialog from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.settings import get_settings from PySide6.QtCore import QTimer -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget -def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): +@pytest.mark.gui +def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...)) app = QApplication.instance() parent = QWidget() @@ -17,12 +15,10 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): qtbot.addWidget(dlg) dlg.show() + dlg.path_edit.setText(str(tmp_path / "alt.db")) dlg.idle_spin.setValue(3) dlg.theme_light.setChecked(True) dlg.move_todos.setChecked(True) - dlg.tags.setChecked(False) - dlg.time_log.setChecked(False) - dlg.reminders.setChecked(False) # Auto-accept the modal QMessageBox that _compact_btn_clicked() shows def _auto_accept_msgbox(): @@ -36,15 +32,18 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): dlg._save() cfg = dlg.config + assert cfg.path.name == "alt.db" assert cfg.idle_minutes == 3 - assert cfg.move_todos is True - assert cfg.tags is False - assert cfg.time_log is False - assert cfg.reminders is False assert cfg.theme in ("light", "dark", "system") def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication, QMessageBox + from bouquin.key_prompt import KeyPrompt + from bouquin.theme import ThemeManager, ThemeConfig, Theme + from PySide6.QtWidgets import QWidget + parent = QWidget() parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -60,7 +59,7 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): def _pump(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.key_entry.setText("supersecret") + w.edit.setText("supersecret") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -82,6 +81,12 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + from bouquin.key_prompt import KeyPrompt + from bouquin.db import DBManager, DBConfig + from bouquin.theme import ThemeManager, ThemeConfig, Theme + cfg = DBConfig( path=tmp_path / "iso.db", key="oldkey", @@ -104,7 +109,7 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): def _pump_popups(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.key_entry.setText(keys.pop(0) if keys else "zzz") + w.edit.setText(keys.pop(0) if keys else "zzz") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -124,6 +129,12 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): def test_change_key_success(qtbot, tmp_path, app): + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication, QWidget, QMessageBox + from bouquin.key_prompt import KeyPrompt + from bouquin.db import DBManager, DBConfig + from bouquin.theme import ThemeManager, ThemeConfig, Theme + cfg = DBConfig( path=tmp_path / "iso2.db", key="oldkey", @@ -146,7 +157,7 @@ def test_change_key_success(qtbot, tmp_path, app): def _pump(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.key_entry.setText(keys.pop(0) if keys else "newkey") + w.edit.setText(keys.pop(0) if keys else "newkey") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -167,246 +178,3 @@ def test_change_key_success(qtbot, tmp_path, app): assert db2.connect() assert "seed" in db2.get_entry("2001-01-01") db2.close() - - -def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db): - # Parent with ThemeManager (dialog uses parent().themes.set(...)) - parent = QWidget() - parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent) - qtbot.addWidget(dlg) - dlg.show() - - # Monkeypatch db.compact to raise - def boom(): - raise RuntimeError("nope") - - dlg._db.compact = boom # type: ignore - - called = {"critical": False, "title": None, "text": None} - - class DummyMB: - @staticmethod - def information(*args, **kwargs): - return 0 - - @staticmethod - def critical(parent, title, text, *rest): - called["critical"] = True - called["title"] = title - called["text"] = str(text) - return 0 - - # Swap QMessageBox used inside the dialog module so signature mismatch can't occur - monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True) - - # Invoke - dlg._compact_btn_clicked() - - assert called["critical"] - assert called["title"] - assert called["text"] - - -class _Host(QWidget): - def __init__(self, themes): - super().__init__() - self.themes = themes - - -def _make_host_and_dialog(tmp_db_cfg, fresh_db): - # Create a real ThemeManager so we don't have to fake anything here - from PySide6.QtWidgets import QApplication - - themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM)) - host = _Host(themes) - dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host) - return host, dlg - - -def _clear_qsettings_theme_to_system(): - """Make the radio-button default deterministic across the full suite.""" - s = get_settings() - s.clear() - s.setValue("ui/theme", "system") - - -def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db): - # Ensure no stray theme value from previous tests - _clear_qsettings_theme_to_system() - - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(host) - qtbot.addWidget(dlg) - # With fresh settings (system), the 'system' radio should be selected - assert dlg.theme_system.isChecked() - - -def test_save_selects_system_when_no_explicit_choice( - qtbot, tmp_db_cfg, fresh_db, monkeypatch -): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - # Ensure neither dark nor light is checked so SYSTEM path is taken - dlg.theme_dark.setChecked(False) - dlg.theme_light.setChecked(False) - # This should not raise - dlg._save() - - -def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - dlg.theme_dark.setChecked(True) - dlg._save() - - -def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - - class P1: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Rejected - - def key(self): - return "" - - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1) - dlg._change_key() # returns early - - -def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - - class P1: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Accepted - - def key(self): - return "abc" - - class P2: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Rejected - - def key(self): - return "abc" - - # First call yields P1, second yields P2 - seq = [P1, P2] - - def _factory(*a, **k): - cls = seq.pop(0) - return cls(*a, **k) - - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory) - dlg._change_key() # returns early - - -def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - - # Timer that auto-accepts any modal QMessageBox so we don't hang. - def _pump_boxes(): - # Try both the active modal and the general top-level enumeration - m = QApplication.activeModalWidget() - if isinstance(m, QMessageBox): - m.accept() - for w in QApplication.topLevelWidgets(): - if isinstance(w, QMessageBox): - w.accept() - - timer = QTimer() - timer.setInterval(10) - timer.timeout.connect(_pump_boxes) - timer.start() - - try: - - class P1: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Accepted - - def key(self): - return "" - - class P2: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Accepted - - def key(self): - return "" - - seq = [P1, P2, P1, P2] - - def _factory(*a, **k): - cls = seq.pop(0) - return cls(*a, **k) - - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory) - # First run triggers empty-key warning path and return (auto-closed) - dlg._change_key() - - # Now make rekey() raise to hit the except block (critical dialog) - def boom(*a, **k): - raise RuntimeError("nope") - - dlg._db.rekey = boom - - # Return a non-empty matching key twice - class P3: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Accepted - - def key(self): - return "secret" - - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3()) - dlg._change_key() - finally: - timer.stop() - - -def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch): - host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db) - qtbot.addWidget(dlg) - - # Make sure we start with no key saved so it will prompt - dlg.key = "" - - class P1: - def __init__(self, *a, **k): - pass - - def exec(self): - return QDialog.Rejected - - def key(self): - return "" - - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1) - - dlg.save_key_btn.setChecked(True) # toggles and calls handler - # Handler should have undone the checkbox back to False - assert not dlg.save_key_btn.isChecked() diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py deleted file mode 100644 index 8ff73b1..0000000 --- a/tests/test_statistics_dialog.py +++ /dev/null @@ -1,636 +0,0 @@ -import datetime as _dt -from datetime import datetime, timedelta, date - -from bouquin import strings - -from PySide6.QtCore import Qt, QPoint, QDate -from PySide6.QtWidgets import QLabel, QWidget -from PySide6.QtTest import QTest - -from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog - - -class FakeStatsDB: - """Minimal stub that returns a fixed stats payload.""" - - def __init__(self): - d1 = _dt.date(2024, 1, 1) - d2 = _dt.date(2024, 1, 2) - self.stats = ( - 2, # pages_with_content - 5, # total_revisions - "2024-01-02", # page_most_revisions - 3, # page_most_revisions_count - {d1: 10, d2: 20}, # words_by_date - 30, # total_words - 4, # unique_tags - "2024-01-02", # page_most_tags - 2, # page_most_tags_count - {d1: 1, d2: 2}, # revisions_by_date - ) - self.called = False - - def gather_stats(self): - self.called = True - return self.stats - - -def test_statistics_dialog_populates_fields_and_heatmap(qtbot): - # Make sure we have a known language for label texts - strings.load_strings("en") - - db = FakeStatsDB() - dlg = StatisticsDialog(db) - qtbot.addWidget(dlg) - dlg.show() - - # Stats were actually requested from the DB - assert db.called - - # Window title comes from translations - assert dlg.windowTitle() == strings._("statistics") - - # Grab all label texts for simple content checks - label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)} - - # Page with most revisions / tags are rendered as "DATE (COUNT)" - assert "2024-01-02 (3)" in label_texts - assert "2024-01-02 (2)" in label_texts - - # Heatmap is created and uses "words" by default - words_by_date = db.stats[4] - revisions_by_date = db.stats[-1] - - assert hasattr(dlg, "_heatmap") - assert dlg._heatmap._data == words_by_date - - # Switching the metric to "revisions" should swap the dataset - dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions - qtbot.wait(10) - assert dlg._heatmap._data == revisions_by_date - - -class EmptyStatsDB: - """Stub that returns a 'no data yet' stats payload.""" - - def __init__(self): - self.called = False - - def gather_stats(self): - self.called = True - return ( - 0, # pages_with_content - 0, # total_revisions - None, # page_most_revisions - 0, - {}, # words_by_date - 0, # total_words - 0, # unique_tags - None, # page_most_tags - 0, - {}, # revisions_by_date - ) - - -def test_statistics_dialog_no_data_shows_placeholder(qtbot): - strings.load_strings("en") - - db = EmptyStatsDB() - dlg = StatisticsDialog(db) - qtbot.addWidget(dlg) - dlg.show() - - assert db.called - - label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)] - assert strings._("stats_no_data") in label_texts - - # When there's no data, the heatmap and metric combo shouldn't exist - assert not hasattr(dlg, "metric_combo") - assert not hasattr(dlg, "_heatmap") - - -def _date(year, month, day): - return date(year, month, day) - - -# ============================================================================ -# DateHeatmapTests - Missing Coverage -# ============================================================================ - - -def test_activity_heatmap_empty_data(qtbot): - """Test heatmap with empty data dict.""" - strings.load_strings("en") - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Set empty data - heatmap.set_data({}) - - # Should handle empty data gracefully - assert heatmap._start is None - assert heatmap._end is None - assert heatmap._max_value == 0 - - # Size hint should return default dimensions - size = heatmap.sizeHint() - assert size.width() > 0 - assert size.height() > 0 - - # Paint should not crash - heatmap.update() - qtbot.wait(10) - - -def test_activity_heatmap_none_data(qtbot): - """Test heatmap with None data.""" - strings.load_strings("en") - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Set None data - heatmap.set_data(None) - - assert heatmap._start is None - assert heatmap._end is None - - # Paint event should return early - heatmap.update() - qtbot.wait(10) - - -def test_activity_heatmap_click_when_no_data(qtbot): - """Test clicking heatmap when there's no data.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - heatmap.set_data({}) - - # Simulate click - should not crash or emit signal - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Click in the middle of widget - pos = QPoint(100, 100) - QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) - - # Should not have clicked any date - assert len(clicked_dates) == 0 - - -def test_activity_heatmap_click_outside_grid(qtbot): - """Test clicking outside the grid area.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Set some data - data = { - date(2024, 1, 1): 5, - date(2024, 1, 2): 10, - } - heatmap.set_data(data) - - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Click in top-left margin (before grid starts) - pos = QPoint(5, 5) - QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) - - assert len(clicked_dates) == 0 - - -def test_activity_heatmap_click_beyond_end_date(qtbot): - """Test clicking on trailing empty cells beyond the last date.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Set data that doesn't fill a complete week - data = { - date(2024, 1, 1): 5, # Monday - date(2024, 1, 2): 10, # Tuesday - } - heatmap.set_data(data) - - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Try clicking far to the right (beyond end date) - # This is tricky to target precisely, but we can simulate - pos = QPoint(1000, 50) # Far right - QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) - - # Should either not click or only click valid dates - # If it did click, it should be a valid date within range - if clicked_dates: - assert clicked_dates[0] <= date(2024, 1, 2) - - -def test_activity_heatmap_click_invalid_row(qtbot): - """Test clicking below the 7 weekday rows.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - data = { - date(2024, 1, 1): 5, - date(2024, 1, 8): 10, - } - heatmap.set_data(data) - - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Click below the grid (row 8 or higher) - pos = QPoint(100, 500) # Very low Y - QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) - - assert len(clicked_dates) == 0 - - -def test_activity_heatmap_right_click_ignored(qtbot): - """Test that right-click is ignored.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - data = {date(2024, 1, 1): 5} - heatmap.set_data(data) - - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Right click should be ignored - pos = QPoint(100, 100) - QTest.mouseClick(heatmap, Qt.RightButton, pos=pos) - - assert len(clicked_dates) == 0 - - -def test_activity_heatmap_month_label_rendering(qtbot): - """Test heatmap spanning multiple months renders month labels.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Data spanning multiple months - data = { - date(2024, 1, 1): 5, - date(2024, 1, 15): 10, - date(2024, 2, 1): 8, - date(2024, 2, 15): 12, - date(2024, 3, 1): 6, - } - heatmap.set_data(data) - - # Should calculate proper size - size = heatmap.sizeHint() - assert size.width() > 0 - assert size.height() > 0 - - # Paint should work without crashing - heatmap.update() - qtbot.wait(10) - - -def test_activity_heatmap_same_month_continues(qtbot): - """Test that month labels skip weeks in the same month.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Multiple dates in same month - data = {} - for day in range(1, 29): # January 1-28 - data[date(2024, 1, day)] = day - - heatmap.set_data(data) - - # Should render without issues - heatmap.update() - qtbot.wait(10) - - -def test_activity_heatmap_data_with_zero_values(qtbot): - """Test heatmap with zero values in data.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - data = { - date(2024, 1, 1): 0, - date(2024, 1, 2): 5, - date(2024, 1, 3): 0, - } - heatmap.set_data(data) - - assert heatmap._max_value == 5 - - heatmap.update() - qtbot.wait(10) - - -def test_activity_heatmap_single_day(qtbot): - """Test heatmap with just one day of data.""" - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - data = {date(2024, 1, 15): 10} - heatmap.set_data(data) - - # Should handle single day - assert heatmap._start is not None - assert heatmap._end is not None - - clicked_dates = [] - heatmap.date_clicked.connect(clicked_dates.append) - - # Click should work - pos = QPoint(100, 100) - QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) - - -# ============================================================================ -# StatisticsDialog Tests -# ============================================================================ - - -def test_statistics_dialog_with_empty_database(qtbot, fresh_db): - """Test statistics dialog with an empty database.""" - strings.load_strings("en") - - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - dialog.show() - - # Should handle empty database gracefully - assert dialog.isVisible() - - # Heatmap should be empty - heatmap = dialog.findChild(DateHeatmap) - if heatmap: - # No crash when displaying empty heatmap - qtbot.wait(10) - - -def test_statistics_dialog_with_data(qtbot, fresh_db): - """Test statistics dialog with actual data.""" - strings.load_strings("en") - - # Add some content - fresh_db.save_new_version("2024-01-01", "Hello world", "test") - fresh_db.save_new_version("2024-01-02", "More content here", "test") - fresh_db.save_new_version("2024-01-03", "Even more text", "test") - - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - dialog.show() - - # Should display statistics - assert dialog.isVisible() - qtbot.wait(10) - - -def test_statistics_dialog_gather_stats_exception_handling( - qtbot, fresh_db, monkeypatch -): - """Test that gather_stats handles exceptions gracefully.""" - strings.load_strings("en") - - # Make dates_with_content raise an exception - def bad_dates_with_content(): - raise RuntimeError("Simulated DB error") - - monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content) - - # Should still create dialog without crashing - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - dialog.show() - - # Should handle error gracefully - assert dialog.isVisible() - - -def test_statistics_dialog_with_sparse_data(qtbot, tmp_db_cfg, fresh_db): - """Test statistics dialog with sparse data""" - # Add some entries on non-consecutive days - dates = ["2024-01-01", "2024-01-05", "2024-01-10", "2024-01-20"] - for _date in dates: - content = "Word " * 100 # 100 words - fresh_db.save_new_version(_date, content, "note") - - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - - # Should create without crashing - assert dialog is not None - - -def test_statistics_dialog_with_empty_data(qtbot, tmp_db_cfg, fresh_db): - """Test statistics dialog with no data""" - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - - # Should handle empty data gracefully - assert dialog is not None - - -def test_statistics_dialog_date_range_selection(qtbot, tmp_db_cfg, fresh_db): - """Test changing metric in statistics dialog""" - # Add some test data - for i in range(10): - date = QDate.currentDate().addDays(-i).toString("yyyy-MM-dd") - fresh_db.save_new_version(date, f"Content for day {i}", "note") - - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - - # Change metric to revisions - idx = dialog.metric_combo.findData("revisions") - if idx >= 0: - dialog.metric_combo.setCurrentIndex(idx) - qtbot.wait(50) - - # Change back to words - idx = dialog.metric_combo.findData("words") - if idx >= 0: - dialog.metric_combo.setCurrentIndex(idx) - qtbot.wait(50) - - -def test_heatmap_with_varying_word_counts(qtbot): - """Test heatmap color scaling with varying word counts""" - today = datetime.now().date() - start = today - timedelta(days=30) - - entries = {} - # Create entries with varying word counts - for i in range(31): - date = start + timedelta(days=i) - entries[date] = i * 50 # Increasing word counts - - heatmap = DateHeatmap() - heatmap.set_data(entries) - qtbot.addWidget(heatmap) - heatmap.show() - - # Should paint without errors - assert heatmap.isVisible() - - -def test_heatmap_single_day(qtbot): - """Test heatmap with single day of data""" - today = datetime.now().date() - entries = {today: 500} - - heatmap = DateHeatmap() - heatmap.set_data(entries) - qtbot.addWidget(heatmap) - heatmap.show() - - assert heatmap.isVisible() - - -def test_statistics_dialog_metric_changes(qtbot, tmp_db_cfg, fresh_db): - """Test various metric selections""" - # Add data spanning multiple months - base_date = QDate.currentDate().addDays(-90) - for i in range(90): - date = base_date.addDays(i).toString("yyyy-MM-dd") - fresh_db.save_new_version(date, f"Day {i} content with many words", "note") - - dialog = StatisticsDialog(fresh_db) - qtbot.addWidget(dialog) - - # Test each metric option - for i in range(dialog.metric_combo.count()): - dialog.metric_combo.setCurrentIndex(i) - qtbot.wait(50) - - -def test_heatmap_date_beyond_end(qtbot, fresh_db): - """Test clicking on a date beyond the end date in heatmap.""" - # Create entries spanning a range - today = date.today() - start = today - timedelta(days=30) - - data = {} - for i in range(20): - d = start + timedelta(days=i) - fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}") - data[d] = 1 - - w = QWidget() - qtbot.addWidget(w) - - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - - # Set data - heatmap.set_data(data) - - # Try to click beyond the end date - should return early - # Calculate a position that would be beyond the end - if heatmap._start and heatmap._end: - cell_span = heatmap._cell + heatmap._gap - weeks = ((heatmap._end - heatmap._start).days + 6) // 7 - - # Click beyond the last week - x = heatmap._margin_left + (weeks + 1) * cell_span + 5 - y = heatmap._margin_top + 3 * cell_span + 5 - - QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y))) - - -def test_heatmap_click_outside_grid(qtbot, fresh_db): - """Test clicking outside the heatmap grid area.""" - today = date.today() - start = today - timedelta(days=7) - - data = {} - for i in range(7): - d = start + timedelta(days=i) - fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}") - data[d] = 1 - - w = QWidget() - qtbot.addWidget(w) - - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - heatmap.set_data(data) - - # Click in the margin (outside grid) - x = heatmap._margin_left - 10 # Before the grid - y = heatmap._margin_top - 10 # Above the grid - - QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y))) - - # Should not crash, just return early - - -def test_heatmap_click_invalid_row(qtbot, fresh_db): - """Test clicking on an invalid row (>= 7).""" - today = date.today() - start = today - timedelta(days=7) - - data = {} - for i in range(7): - d = start + timedelta(days=i) - fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}") - data[d] = 1 - - w = QWidget() - qtbot.addWidget(w) - - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - heatmap.set_data(data) - - # Click below row 6 (day of week > Sunday) - cell_span = heatmap._cell + heatmap._gap - x = heatmap._margin_left + 5 - y = heatmap._margin_top + 7 * cell_span + 5 # Row 7, which is invalid - - QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y))) - - # Should return early, not crash - - -def test_heatmap_month_label_continuation(qtbot, fresh_db): - """Test that month labels don't repeat when continuing in same month.""" - # Create a date range that spans multiple weeks within the same month - today = date.today() - # Use a date that's guaranteed to be mid-month - start = date(today.year, today.month, 1) - - data = {} - for i in range(21): - d = start + timedelta(days=i) - fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}") - data[d] = 1 - - w = QWidget() - qtbot.addWidget(w) - - heatmap = DateHeatmap() - qtbot.addWidget(heatmap) - heatmap.show() - heatmap.set_data(data) - - # Force a repaint to execute paintEvent - heatmap.repaint() - - # The month continuation logic (line 175) should prevent duplicate labels - # We can't easily test the visual output, but we ensure no crash diff --git a/tests/test_strings.py b/tests/test_strings.py deleted file mode 100644 index ec2c445..0000000 --- a/tests/test_strings.py +++ /dev/null @@ -1,17 +0,0 @@ -from bouquin import strings - - -def test_load_strings_uses_system_locale_and_fallback(): - # pass a bogus locale to trigger fallback-to-default - strings.load_strings("zz") - assert strings._("next") # key exists in base translations - - -def test_load_strings_french(): - strings.load_strings("fr") - assert strings._("today") == "Aujourd'hui" # translation exists in French - - -def test_load_strings_italian(): - strings.load_strings("it") - assert strings._("today") == "Oggi" # translation exists in Italian diff --git a/tests/test_tabs.py b/tests/test_tabs.py deleted file mode 100644 index fe73828..0000000 --- a/tests/test_tabs.py +++ /dev/null @@ -1,195 +0,0 @@ -import types -from PySide6.QtWidgets import QFileDialog -from PySide6.QtGui import QTextCursor - - -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.settings import get_settings -from bouquin.main_window import MainWindow -from bouquin.history_dialog import HistoryDialog - - -def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): - # point to the temp encrypted DB - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() - - # first tab is today's date - date1 = w.calendar.selectedDate() - initial_count = w.tab_widget.count() - - # opening the same date should NOT create a new tab - w._open_date_in_tab(date1) - assert w.tab_widget.count() == initial_count - assert w.tab_widget.currentWidget().current_date == date1 - - # opening a different date should create exactly one new tab - date2 = date1.addDays(1) - w._open_date_in_tab(date2) - assert w.tab_widget.count() == initial_count + 1 - assert w.tab_widget.currentWidget().current_date == date2 - - # jumping back to date1 just focuses the existing tab - w._open_date_in_tab(date1) - assert w.tab_widget.count() == initial_count + 1 - assert w.tab_widget.currentWidget().current_date == date1 - - -def test_toolbar_signals_dispatch_once_per_click( - qtbot, app, tmp_db_cfg, fresh_db, monkeypatch -): - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() - - tb = w.toolBar - - # Spy on the first tab's editor - calls1 = { - "bold": 0, - "italic": 0, - "strike": 0, - "code": 0, - "heading": 0, - "bullets": 0, - "numbers": 0, - "checkboxes": 0, - } - - def mk(key): - def _spy(self, *a, **k): - calls1[key] += 1 - - return _spy - - w.editor.apply_weight = types.MethodType(mk("bold"), w.editor) - w.editor.apply_italic = types.MethodType(mk("italic"), w.editor) - w.editor.apply_strikethrough = types.MethodType(mk("strike"), w.editor) - w.editor.apply_code = types.MethodType(mk("code"), w.editor) - w.editor.apply_heading = types.MethodType(mk("heading"), w.editor) - w.editor.toggle_bullets = types.MethodType(mk("bullets"), w.editor) - w.editor.toggle_numbers = types.MethodType(mk("numbers"), w.editor) - w.editor.toggle_checkboxes = types.MethodType(mk("checkboxes"), w.editor) - - # Click all the things once - tb.boldRequested.emit() - tb.italicRequested.emit() - tb.strikeRequested.emit() - tb.codeRequested.emit() - tb.headingRequested.emit(24) - tb.bulletsRequested.emit() - tb.numbersRequested.emit() - tb.checkboxesRequested.emit() - - assert all(v == 1 for v in calls1.values()) # fired once each - - # Switch to a new tab and make sure clicks go ONLY to the active editor - date2 = w.calendar.selectedDate().addDays(1) - w._open_date_in_tab(date2) - calls2 = {"bold": 0} - w.editor.apply_weight = types.MethodType( - lambda self: calls2.__setitem__("bold", calls2["bold"] + 1), w.editor - ) - - tb.boldRequested.emit() - assert calls1["bold"] == 1 - assert calls2["bold"] == 1 - - w._open_date_in_tab(date2.addDays(-1)) # back to first tab - tb.boldRequested.emit() - assert calls1["bold"] == 2 - assert calls2["bold"] == 1 - - -def test_history_and_insert_image_not_duplicated( - qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path -): - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() - - # History dialog opens exactly once - opened = {"count": 0} - - def fake_exec(self): - opened["count"] += 1 - return 0 # Rejected - - monkeypatch.setattr(HistoryDialog, "exec", fake_exec, raising=False) - w.toolBar.historyRequested.emit() - assert opened["count"] == 1 - - # Insert image: simulate user selecting one file, and ensure it's inserted once - dummy = tmp_path / "x.png" - dummy.write_bytes(b"\x89PNG\r\n\x1a\n") - inserted = {"count": 0} - - def fake_insert(self, p): - inserted["count"] += 1 - - w.editor.insert_image_from_path = types.MethodType(fake_insert, w.editor) - monkeypatch.setattr( - QFileDialog, - "getOpenFileNames", - lambda *a, **k: ([str(dummy)], "Images (*.png)"), - raising=False, - ) - w.toolBar.insertImageRequested.emit() - assert inserted["count"] == 1 - - -def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() - - w.editor.from_markdown("**bold**\n- [ ] task\n~~strike~~") - assert w.editor.highlighter is not None - assert w.editor.highlighter.document() is w.editor.document() - - -def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() - - # Tab 1 content - w.editor.from_markdown("alpha bravo charlie") - w.findBar.show_bar() - w.findBar.edit.setText("bravo") - w.findBar.find_next() - assert w.editor.textCursor().selectedText() == "bravo" - - # Tab 2 content (contains the query too) - date2 = w.calendar.selectedDate().addDays(1) - w._open_date_in_tab(date2) - w.editor.from_markdown("x bravo y bravo z") - w.editor.moveCursor(QTextCursor.Start) - w.findBar.find_next() - assert w.editor.textCursor().selectedText() == "bravo" diff --git a/tests/test_tags.py b/tests/test_tags.py deleted file mode 100644 index 8564c6b..0000000 --- a/tests/test_tags.py +++ /dev/null @@ -1,2319 +0,0 @@ -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 index 6f19a62..690f439 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,5 +1,5 @@ +import pytest from PySide6.QtGui import QPalette -from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from bouquin.theme import Theme, ThemeConfig, ThemeManager @@ -14,39 +14,8 @@ def test_theme_manager_apply_light_and_dark(app): assert isinstance(app.palette(), QPalette) +@pytest.mark.gui def test_theme_manager_system_roundtrip(app, qtbot): cfg = ThemeConfig(theme=Theme.SYSTEM) mgr = ThemeManager(app, cfg) mgr.apply(cfg.theme) - - -def _make_themes(theme): - app = QApplication.instance() - return ThemeManager(app, ThemeConfig(theme=theme)) - - -def test_register_and_restyle_calendar_and_overlay(qtbot): - themes = _make_themes(Theme.DARK) - cal = QCalendarWidget() - ov = QWidget() - ov.setObjectName("LockOverlay") - qtbot.addWidget(cal) - qtbot.addWidget(ov) - - themes.register_calendar(cal) - themes.register_lock_overlay(ov) - - # Force a restyle pass (covers the "is not None" branches) - themes._restyle_registered() - - -def test_apply_dark_styles_cover_css_paths(qtbot): - themes = _make_themes(Theme.DARK) - cal = QCalendarWidget() - ov = QWidget() - ov.setObjectName("LockOverlay") - qtbot.addWidget(cal) - qtbot.addWidget(ov) - - themes.register_calendar(cal) # drives _apply_calendar_theme (dark path) - themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path) diff --git a/tests/test_time_log.py b/tests/test_time_log.py deleted file mode 100644 index 68dad54..0000000 --- a/tests/test_time_log.py +++ /dev/null @@ -1,2598 +0,0 @@ -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 index 3794760..1022172 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -2,7 +2,6 @@ import pytest from PySide6.QtWidgets import QWidget from bouquin.markdown_editor import MarkdownEditor from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.toolbar import ToolBar @pytest.fixture @@ -14,6 +13,10 @@ def editor(app, qtbot): return ed +from bouquin.toolbar import ToolBar + + +@pytest.mark.gui def test_toolbar_signals_and_styling(qtbot, editor): host = QWidget() qtbot.addWidget(host) @@ -39,29 +42,3 @@ def test_toolbar_signals_and_styling(qtbot, editor): tb.strikeRequested.emit() tb.headingRequested.emit(24) assert editor.to_markdown() - - -def test_style_letter_button_paths(app, qtbot): - parent = QWidget() - qtbot.addWidget(parent) - # Create toolbar - from bouquin.markdown_editor import MarkdownEditor - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - ed = MarkdownEditor(themes) - qtbot.addWidget(ed) - tb = ToolBar(parent) - qtbot.addWidget(tb) - - # Action not added to toolbar -> no widget, early return - from PySide6.QtGui import QAction - - stray = QAction("Stray", tb) - tb._style_letter_button(stray, "Z") # should not raise - - # Now add an action to toolbar and style with tooltip - act = tb.addAction("Temp") - tb._style_letter_button(act, "T", tooltip="Tip here") - btn = tb.widgetForAction(act) - assert btn.toolTip() == "Tip here" - assert btn.accessibleName() == "Tip here" diff --git a/tests/test_version_check.py b/tests/test_version_check.py deleted file mode 100644 index b5afe12..0000000 --- a/tests/test_version_check.py +++ /dev/null @@ -1,534 +0,0 @@ -import pytest -from unittest.mock import Mock, patch -import subprocess -from bouquin.version_check import VersionChecker -from PySide6.QtWidgets import QMessageBox, QWidget -from PySide6.QtGui import QPixmap - - -def test_version_checker_init(app): - """Test VersionChecker initialization.""" - parent = QWidget() - checker = VersionChecker(parent) - - assert checker._parent is parent - - -def test_version_checker_init_no_parent(app): - """Test VersionChecker initialization without parent.""" - checker = VersionChecker() - - assert checker._parent is None - - -def test_current_version_returns_version(app): - """Test getting current version.""" - checker = VersionChecker() - - with patch("importlib.metadata.version", return_value="1.2.3"): - version = checker.current_version() - assert version == "1.2.3" - - -def test_current_version_fallback_on_error(app): - """Test current version fallback when package not found.""" - checker = VersionChecker() - - import importlib.metadata - - with patch( - "importlib.metadata.version", - side_effect=importlib.metadata.PackageNotFoundError("Not found"), - ): - version = checker.current_version() - assert version == "0.0.0" - - -def test_parse_version_simple(app): - """Test parsing simple version string.""" - result = VersionChecker._parse_version("1.2.3") - assert result == (1, 2, 3) - - -def test_parse_version_complex(app): - """Test parsing complex version string with extra text.""" - result = VersionChecker._parse_version("v1.2.3-beta") - assert result == (1, 2, 3) - - -def test_parse_version_no_numbers(app): - """Test parsing version string with no numbers.""" - result = VersionChecker._parse_version("invalid") - assert result == (0,) - - -def test_parse_version_single_number(app): - """Test parsing version with single number.""" - result = VersionChecker._parse_version("5") - assert result == (5,) - - -def test_is_newer_version_true(app): - """Test detecting newer version.""" - checker = VersionChecker() - - assert checker._is_newer_version("1.2.3", "1.2.2") is True - assert checker._is_newer_version("2.0.0", "1.9.9") is True - assert checker._is_newer_version("1.3.0", "1.2.9") is True - - -def test_is_newer_version_false(app): - """Test detecting same or older version.""" - checker = VersionChecker() - - assert checker._is_newer_version("1.2.3", "1.2.3") is False - assert checker._is_newer_version("1.2.2", "1.2.3") is False - assert checker._is_newer_version("0.9.9", "1.0.0") is False - - -def test_logo_pixmap(app): - """Test generating logo pixmap.""" - checker = VersionChecker() - - pixmap = checker._logo_pixmap(96) - - assert isinstance(pixmap, QPixmap) - assert not pixmap.isNull() - - -def test_logo_pixmap_different_sizes(app): - """Test generating logo pixmap with different sizes.""" - checker = VersionChecker() - - pixmap_small = checker._logo_pixmap(48) - pixmap_large = checker._logo_pixmap(128) - - assert not pixmap_small.isNull() - assert not pixmap_large.isNull() - - -def test_show_version_dialog(qtbot, app): - """Test showing version dialog.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - with patch.object(QMessageBox, "exec") as mock_exec: - with patch("importlib.metadata.version", return_value="1.0.0"): - checker.show_version_dialog() - - # Dialog should have been shown - assert mock_exec.called - - -def test_check_for_updates_network_error(qtbot, app): - """Test check for updates when network request fails.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - with patch("requests.get", side_effect=Exception("Network error")): - with patch.object(QMessageBox, "warning") as mock_warning: - checker.check_for_updates() - - # Should show warning - assert mock_warning.called - - -def test_check_for_updates_empty_response(qtbot, app): - """Test check for updates with empty version string.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = " " - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch.object(QMessageBox, "warning") as mock_warning: - checker.check_for_updates() - - # Should show warning about empty version - assert mock_warning.called - - -def test_check_for_updates_already_latest(qtbot, app): - """Test check for updates when already on latest version.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "1.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "information") as mock_info: - checker.check_for_updates() - - # Should show info that we're on latest - assert mock_info.called - - -def test_check_for_updates_new_version_available_declined(qtbot, app): - """Test check for updates when new version is available but user declines.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.No): - # Should not proceed to download - checker.check_for_updates() - - -def test_check_for_updates_new_version_available_accepted(qtbot, app): - """Test check for updates when new version is available and user accepts.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - with patch.object( - checker, "_download_and_verify_appimage" - ) as mock_download: - checker.check_for_updates() - - # Should call download - mock_download.assert_called_once_with("2.0.0") - - -def test_download_file_success(qtbot, app, tmp_path): - """Test downloading a file successfully.""" - checker = VersionChecker() - - mock_response = Mock() - mock_response.headers = {"Content-Length": "1000"} - mock_response.iter_content = Mock(return_value=[b"data" * 25]) # 100 bytes - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "test_file.bin" - - with patch("requests.get", return_value=mock_response): - checker._download_file("http://example.com/file", dest_path) - - assert dest_path.exists() - - -def test_download_file_with_progress(qtbot, app, tmp_path): - """Test downloading a file with progress dialog.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.headers = {"Content-Length": "1000"} - mock_response.iter_content = Mock(return_value=[b"x" * 100, b"y" * 100]) - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "test_file.bin" - - from PySide6.QtWidgets import QProgressDialog - - mock_progress = Mock(spec=QProgressDialog) - mock_progress.wasCanceled = Mock(return_value=False) - mock_progress.value = Mock(return_value=0) - - with patch("requests.get", return_value=mock_response): - checker._download_file( - "http://example.com/file", dest_path, progress=mock_progress - ) - - # Progress should have been updated - assert mock_progress.setValue.called - - -def test_download_file_cancelled(qtbot, app, tmp_path): - """Test cancelling a file download.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.headers = {"Content-Length": "1000"} - mock_response.iter_content = Mock(return_value=[b"x" * 100]) - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "test_file.bin" - - from PySide6.QtWidgets import QProgressDialog - - mock_progress = Mock(spec=QProgressDialog) - mock_progress.wasCanceled = Mock(return_value=True) - mock_progress.value = Mock(return_value=0) - - with patch("requests.get", return_value=mock_response): - with pytest.raises(RuntimeError): - checker._download_file( - "http://example.com/file", dest_path, progress=mock_progress - ) - - -def test_download_file_no_content_length(qtbot, app, tmp_path): - """Test downloading file without Content-Length header.""" - checker = VersionChecker() - - mock_response = Mock() - mock_response.headers = {} - mock_response.iter_content = Mock(return_value=[b"data"]) - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "test_file.bin" - - with patch("requests.get", return_value=mock_response): - checker._download_file("http://example.com/file", dest_path) - - assert dest_path.exists() - - -def test_download_and_verify_appimage_download_cancelled(qtbot, app, tmp_path): - """Test AppImage download when user cancels.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object( - checker, "_download_file", side_effect=RuntimeError("Download cancelled") - ): - with patch.object(QMessageBox, "information") as mock_info: - checker._download_and_verify_appimage("2.0.0") - - # Should show cancellation message - assert mock_info.called - - -def test_download_and_verify_appimage_download_error(qtbot, app, tmp_path): - """Test AppImage download when download fails.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object( - checker, "_download_file", side_effect=Exception("Network error") - ): - with patch.object(QMessageBox, "critical") as mock_critical: - checker._download_and_verify_appimage("2.0.0") - - # Should show error message - assert mock_critical.called - - -def test_download_and_verify_appimage_gpg_key_error(qtbot, app, tmp_path): - """Test AppImage verification when GPG key cannot be read.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object(checker, "_download_file"): - with patch( - "importlib.resources.files", side_effect=Exception("Key not found") - ): - with patch.object(QMessageBox, "critical") as mock_critical: - checker._download_and_verify_appimage("2.0.0") - - # Should show error about GPG key - assert mock_critical.called - - -def test_download_and_verify_appimage_gpg_not_found(qtbot, app, tmp_path): - """Test AppImage verification when GPG is not installed.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_files = Mock() - mock_files.read_bytes = Mock(return_value=b"fake key data") - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object(checker, "_download_file"): - with patch("importlib.resources.files", return_value=mock_files): - with patch( - "subprocess.run", side_effect=FileNotFoundError("gpg not found") - ): - with patch.object(QMessageBox, "critical") as mock_critical: - checker._download_and_verify_appimage("2.0.0") - - # Should show error about GPG not found - assert mock_critical.called - - -def test_download_and_verify_appimage_verification_failed(qtbot, app, tmp_path): - """Test AppImage verification when signature verification fails.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_files = Mock() - mock_files.read_bytes = Mock(return_value=b"fake key data") - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object(checker, "_download_file"): - with patch("importlib.resources.files", return_value=mock_files): - # First subprocess call (import) succeeds, second (verify) fails - mock_error = subprocess.CalledProcessError(1, "gpg") - mock_error.stderr = b"Verification failed" - with patch("subprocess.run", side_effect=[None, mock_error]): - with patch.object(QMessageBox, "critical") as mock_critical: - checker._download_and_verify_appimage("2.0.0") - - # Should show error about verification - assert mock_critical.called - - -def test_download_and_verify_appimage_success(qtbot, app, tmp_path): - """Test successful AppImage download and verification.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_files = Mock() - mock_files.read_bytes = Mock(return_value=b"fake key data") - - with patch( - "bouquin.version_check.QStandardPaths.writableLocation", - return_value=str(tmp_path), - ): - with patch.object(checker, "_download_file"): - with patch("importlib.resources.files", return_value=mock_files): - with patch("subprocess.run"): # Both calls succeed - with patch.object(QMessageBox, "information") as mock_info: - checker._download_and_verify_appimage("2.0.0") - - # Should show success message - assert mock_info.called - - -def test_version_comparison_edge_cases(app): - """Test version comparison with edge cases.""" - checker = VersionChecker() - - # Different lengths - assert checker._is_newer_version("1.0.0.1", "1.0.0") is True - assert checker._is_newer_version("1.0", "1.0.0") is False - - # Large numbers - assert checker._is_newer_version("10.0.0", "9.9.9") is True - assert checker._is_newer_version("1.100.0", "1.99.0") is True - - -def test_download_file_creates_parent_directory(qtbot, app, tmp_path): - """Test that download creates parent directory if needed.""" - checker = VersionChecker() - - mock_response = Mock() - mock_response.headers = {} - mock_response.iter_content = Mock(return_value=[b"data"]) - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "subdir" / "nested" / "test_file.bin" - - with patch("requests.get", return_value=mock_response): - checker._download_file("http://example.com/file", dest_path) - - assert dest_path.exists() - assert dest_path.parent.exists() - - -def test_show_version_dialog_check_button_clicked(qtbot, app): - """Test clicking 'Check for updates' button in version dialog.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_box = Mock(spec=QMessageBox) - check_button = Mock() - mock_box.clickedButton = Mock(return_value=check_button) - mock_box.addButton = Mock(return_value=check_button) - - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch("bouquin.version_check.QMessageBox", return_value=mock_box): - with patch.object(checker, "check_for_updates") as mock_check: - checker.show_version_dialog() - - # check_for_updates should be called when button is clicked - if mock_box.clickedButton() is check_button: - assert mock_check.called - - -def test_parse_version_with_letters(app): - """Test parsing version strings with letters.""" - result = VersionChecker._parse_version("1.2.3rc1") - assert 1 in result - assert 2 in result - assert 3 in result - - -def test_download_file_invalid_content_length(qtbot, app, tmp_path): - """Test downloading file with invalid Content-Length header.""" - checker = VersionChecker() - - mock_response = Mock() - mock_response.headers = {"Content-Length": "invalid"} - mock_response.iter_content = Mock(return_value=[b"data"]) - mock_response.raise_for_status = Mock() - - dest_path = tmp_path / "test_file.bin" - - with patch("requests.get", return_value=mock_response): - # Should handle gracefully - checker._download_file("http://example.com/file", dest_path) - - assert dest_path.exists() - - -def test_version_checker_creation(qtbot): - """Test creating a VersionChecker instance.""" - widget = QWidget() - qtbot.addWidget(widget) - - checker = VersionChecker(widget) - assert checker is not None - - -def test_current_version(qtbot): - """Test getting the current version.""" - widget = QWidget() - qtbot.addWidget(widget) - - checker = VersionChecker(widget) - version = checker.current_version() - - # Version should be a string - assert isinstance(version, str) - assert len(version) > 0 diff --git a/vulture_ignorelist.py b/vulture_ignorelist.py deleted file mode 100644 index 2addc55..0000000 --- a/vulture_ignorelist.py +++ /dev/null @@ -1,26 +0,0 @@ -from bouquin.db import DBManager -from bouquin.flow_layout import FlowLayout -from bouquin.markdown_editor import MarkdownEditor -from bouquin.markdown_highlighter import MarkdownHighlighter -from bouquin.statistics_dialog import DateHeatMap - -DBManager.row_factory - -DateHeatMap.minimumSizeHint - -FlowLayout.itemAt -FlowLayout.expandingDirections -FlowLayout.hasHeightForWidth -FlowLayout.heightForWidth - -MarkdownEditor.apply_weight -MarkdownEditor.apply_italic -MarkdownEditor.apply_strikethrough -MarkdownEditor.apply_code -MarkdownEditor.apply_heading -MarkdownEditor.contextMenuEvent -MarkdownEditor.toggle_bullets -MarkdownEditor.toggle_numbers -MarkdownEditor.toggle_checkboxes - -MarkdownHighlighter.highlightBlock