From e0169db52a902a52ae99021e2494729fcb18ad3a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 25 Nov 2025 14:52:26 +1100 Subject: [PATCH] Many changes and new features: * 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 --- CHANGELOG.md | 8 +- bouquin/code_highlighter.py | 365 ++++++++++++++++++ bouquin/db.py | 102 +++++ bouquin/locales/en.json | 44 ++- bouquin/locales/fr.json | 13 +- bouquin/locales/it.json | 13 +- bouquin/main_window.py | 73 ++++ bouquin/markdown_editor.py | 143 ++++++- bouquin/markdown_highlighter.py | 35 +- bouquin/pomodoro_timer.py | 149 ++++++++ bouquin/reminders.py | 637 +++++++++++++++++++++++++++++++ bouquin/settings.py | 3 + bouquin/settings_dialog.py | 6 + bouquin/table_editor.py | 255 +++++++++++++ bouquin/time_log.py | 6 +- bouquin/toolbar.py | 16 + bouquin/version_check.py | 2 +- pyproject.toml | 2 +- tests/conftest.py | 11 +- tests/test_code_highlighter.py | 398 +++++++++++++++++++ tests/test_main_window.py | 5 + tests/test_pomodoro_timer.py | 354 +++++++++++++++++ tests/test_reminders.py | 657 ++++++++++++++++++++++++++++++++ tests/test_settings.py | 12 + tests/test_settings_dialog.py | 2 + tests/test_table_editor.py | 384 +++++++++++++++++++ tests/test_version_check.py | 512 +++++++++++++++++++++++++ vulture_ignorelist.py | 1 + 28 files changed, 4191 insertions(+), 17 deletions(-) create mode 100644 bouquin/code_highlighter.py create mode 100644 bouquin/pomodoro_timer.py create mode 100644 bouquin/reminders.py create mode 100644 bouquin/table_editor.py create mode 100644 tests/test_code_highlighter.py create mode 100644 tests/test_pomodoro_timer.py create mode 100644 tests/test_reminders.py create mode 100644 tests/test_table_editor.py create mode 100644 tests/test_version_check.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7a59c..e4876c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ -# 0.4.6 +# 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 diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py new file mode 100644 index 0000000..b60d8a5 --- /dev/null +++ b/bouquin/code_highlighter.py @@ -0,0 +1,365 @@ +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")) + + # Strings (double quotes) + patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string")) + + # Strings (single quotes) + patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string")) + + # Numbers + patterns.append((r"\b\d+\.?\d*\b", "number")) + + # Keywords + for keyword in keywords: + patterns.append((r"\b" + keyword + r"\b", "keyword")) + + 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 a4f5c67..d6211f5 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -63,6 +63,7 @@ class DBConfig: move_todos: bool = False tags: bool = True time_log: bool = True + reminders: bool = True locale: str = "en" font_size: int = 11 @@ -195,6 +196,20 @@ class DBManager: 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); """ ) self.conn.commit() @@ -1015,3 +1030,90 @@ class DBManager: 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/locales/en.json b/bouquin/locales/en.json index f6be3e1..d6e7ad7 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -49,6 +49,9 @@ "backup_complete": "Backup complete", "backup_failed": "Backup failed", "quit": "Quit", + "cancel": "Cancel", + "ok": "OK", + "save": "Save", "help": "Help", "saved": "Saved", "saved_to": "Saved to", @@ -256,5 +259,44 @@ "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_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", + "edit_table": "Edit table", + "toolbar_insert_table": "Insert table", + "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", + "add_row": "Add row", + "add_column": "Add column", + "delete_row": "Delete row", + "delete_column": "Delete column", + "column": "Column" } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 99c5f66..ecb5000 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -121,5 +121,16 @@ "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à" + "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", + "cut" : "Couper", + "copy" : "Copier", + "paste" : "Coller", + "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 index 6a178bb..6e33083 100644 --- a/bouquin/locales/it.json +++ b/bouquin/locales/it.json @@ -148,5 +148,16 @@ "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" + "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/main_window.py b/bouquin/main_window.py index b26ba2c..17683db 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -57,6 +57,8 @@ 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 @@ -106,12 +108,18 @@ class MainWindow(QMainWindow): 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() @@ -119,6 +127,7 @@ 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) @@ -324,6 +333,10 @@ class MainWindow(QMainWindow): 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._restore_window_position() @@ -1087,6 +1100,8 @@ class MainWindow(QMainWindow): 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_table = self._on_table_requested self._tb_font_larger = self._on_font_larger_requested self._tb_font_smaller = self._on_font_smaller_requested @@ -1099,6 +1114,8 @@ class MainWindow(QMainWindow): 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.tableRequested.connect(self._tb_table) tb.insertImageRequested.connect(self._on_insert_image) tb.historyRequested.connect(self._open_history) tb.fontSizeLargerRequested.connect(self._tb_font_larger) @@ -1228,6 +1245,23 @@ class MainWindow(QMainWindow): # Rebuild timers, but only if this page is for "today" self._rebuild_reminders_for_today() + 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. @@ -1344,6 +1378,36 @@ class MainWindow(QMainWindow): timer.start(msecs) self._reminder_timers.append(timer) + # ----------- Table handler ------------# + def _on_table_requested(self): + """Insert a basic markdown table template.""" + editor = getattr(self, "editor", None) + if editor is None: + return + + # Basic 3x3 table template + table_template = """| Column 1 | Column 2 | Column 3 | +| --- | --- | --- | +| Cell 1 | Cell 2 | Cell 3 | +| Cell 4 | Cell 5 | Cell 6 | +""" + + cursor = editor.textCursor() + cursor.insertText(table_template) + + # Move cursor to first cell for easy editing + # Find the start of "Column 1" text + cursor.movePosition( + QTextCursor.Left, QTextCursor.MoveAnchor, len(table_template) + ) + cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) # After "| " + cursor.movePosition( + QTextCursor.Right, QTextCursor.KeepAnchor, 8 + ) # Select "Column 1" + editor.setTextCursor(cursor) + + editor.setFocus() + # ----------- History handler ------------# def _open_history(self): if hasattr(self.editor, "current_date"): @@ -1444,6 +1508,7 @@ class MainWindow(QMainWindow): 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) @@ -1471,8 +1536,16 @@ class MainWindow(QMainWindow): 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 --------------- # diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 6353034..994ff45 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -22,6 +22,7 @@ from PySide6.QtWidgets import QTextEdit from .theme import ThemeManager from .markdown_highlighter import MarkdownHighlighter +from . import strings class MarkdownEditor(QTextEdit): @@ -63,7 +64,12 @@ class MarkdownEditor(QTextEdit): self._BULLET_STORAGE = "-" # Install syntax highlighter - self.highlighter = MarkdownHighlighter(self.document(), theme_manager) + self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self) + + # Initialize code block metadata + from .code_highlighter import CodeBlockMetadata + + self._code_metadata = CodeBlockMetadata() # Track current list type for smart enter handling self._last_enter_was_empty = False @@ -91,7 +97,9 @@ class MarkdownEditor(QTextEdit): # 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.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) @@ -274,6 +282,12 @@ class MarkdownEditor(QTextEdit): text, ) + # Append code block metadata if present + if hasattr(self, "_code_metadata"): + metadata_str = self._code_metadata.serialize() + if metadata_str: + text = text.rstrip() + "\n\n" + metadata_str + return text def _extract_images_to_markdown(self) -> str: @@ -312,6 +326,16 @@ class MarkdownEditor(QTextEdit): def from_markdown(self, markdown_text: str): """Load markdown text into the editor.""" + # Extract and load code block metadata if present + from .code_highlighter import CodeBlockMetadata + + if not hasattr(self, "_code_metadata"): + self._code_metadata = CodeBlockMetadata() + + self._code_metadata.deserialize(markdown_text) + # Remove metadata comment from displayed text + markdown_text = re.sub(r"\s*\s*$", "", markdown_text) + # Convert markdown checkboxes to Unicode for display display_text = markdown_text.replace( f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} " @@ -432,10 +456,6 @@ class MarkdownEditor(QTextEdit): cursor.select(QTextCursor.SelectionType.LineUnderCursor) return cursor.selectedText() - def get_current_line_text(self) -> str: - """Public wrapper used by MainWindow for reminders.""" - return self._get_current_line() - 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. @@ -1218,3 +1238,114 @@ 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 table + text = self.toPlainText() + cursor_pos = cursor.position() + + from .table_editor import find_table_at_cursor + + table_info = find_table_at_cursor(text, cursor_pos) + + if table_info: + # Add table editing action + edit_table_action = QAction(strings._("edit_table"), self) + edit_table_action.triggered.connect( + lambda: self._edit_table_at_cursor(cursor_pos) + ) + menu.addAction(edit_table_action) + menu.addSeparator() + + # 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 _edit_table_at_cursor(self, cursor_pos: int): + """Open table editor dialog for the table at cursor position.""" + from .table_editor import find_table_at_cursor, TableEditorDialog + from PySide6.QtWidgets import QDialog + + text = self.toPlainText() + table_info = find_table_at_cursor(text, cursor_pos) + + if not table_info: + return + + start_pos, end_pos, table_text = table_info + + # Open table editor + dlg = TableEditorDialog(table_text, self) + if dlg.exec() == QDialog.Accepted: + # Replace the table with edited version + new_table = dlg.get_markdown_table() + + cursor = QTextCursor(self.document()) + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + cursor.insertText(new_table) + + 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 index 06fa57a..b6d7ac8 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -19,9 +19,12 @@ 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): + 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) @@ -149,6 +152,36 @@ class MarkdownHighlighter(QSyntaxHighlighter): 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 diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py new file mode 100644 index 0000000..fd29742 --- /dev/null +++ b/bouquin/pomodoro_timer.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import math +from typing import Optional + +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from . import strings +from .db import DBManager +from .time_log import TimeLogDialog + + +class PomodoroTimer(QDialog): + """A simple timer dialog for tracking work time on a specific task.""" + + timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) + + def __init__(self, task_text: str, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setWindowTitle(strings._("toolbar_pomodoro_timer")) + self.setModal(False) + self.setMinimumWidth(300) + + self._task_text = task_text + self._elapsed_seconds = 0 + self._running = False + + layout = QVBoxLayout(self) + + # Task label + task_label = QLabel(task_text) + task_label.setWordWrap(True) + layout.addWidget(task_label) + + # Timer display + self.time_label = QLabel("00:00:00") + font = self.time_label.font() + font.setPointSize(24) + font.setBold(True) + self.time_label.setFont(font) + self.time_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.time_label) + + # Control buttons + btn_layout = QHBoxLayout() + + self.start_pause_btn = QPushButton(strings._("start")) + self.start_pause_btn.clicked.connect(self._toggle_timer) + btn_layout.addWidget(self.start_pause_btn) + + self.stop_btn = QPushButton(strings._("stop_and_log")) + self.stop_btn.clicked.connect(self._stop_and_log) + self.stop_btn.setEnabled(False) + btn_layout.addWidget(self.stop_btn) + + layout.addLayout(btn_layout) + + # Internal timer (ticks every second) + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + + @Slot() + def _toggle_timer(self): + """Start or pause the timer.""" + if self._running: + # Pause + self._running = False + self._timer.stop() + self.start_pause_btn.setText(strings._("resume")) + else: + # Start/Resume + self._running = True + self._timer.start(1000) # 1 second + self.start_pause_btn.setText(strings._("pause")) + self.stop_btn.setEnabled(True) + + @Slot() + def _tick(self): + """Update the elapsed time display.""" + self._elapsed_seconds += 1 + self._update_display() + + def _update_display(self): + """Update the time display label.""" + hours = self._elapsed_seconds // 3600 + minutes = (self._elapsed_seconds % 3600) // 60 + seconds = self._elapsed_seconds % 60 + self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}") + + @Slot() + def _stop_and_log(self): + """Stop the timer and emit signal to open time log.""" + if self._running: + self._running = False + self._timer.stop() + + self.timerStopped.emit(self._elapsed_seconds, self._task_text) + self.accept() + + +class PomodoroManager: + """Manages Pomodoro timers and integrates with time log.""" + + def __init__(self, db: DBManager, parent_window): + self._db = db + self._parent = parent_window + self._active_timer: Optional[PomodoroTimer] = None + + def start_timer_for_line(self, line_text: str, date_iso: str): + """Start a new timer for the given line of text.""" + # Stop any existing timer + if self._active_timer and self._active_timer.isVisible(): + self._active_timer.close() + + # Create new timer + self._active_timer = PomodoroTimer(line_text, self._parent) + self._active_timer.timerStopped.connect( + lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) + ) + self._active_timer.show() + + def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): + """Handle timer stop - open time log dialog with pre-filled data.""" + # Convert seconds to decimal hours, rounded up + hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour + + # Ensure minimum of 0.25 hours + if hours < 0.25: + hours = 0.25 + + # Open time log dialog + dlg = TimeLogDialog(self._db, date_iso, self._parent) + + # Pre-fill the hours + dlg.hours_spin.setValue(hours) + + # Pre-fill the note with task text + dlg.note.setText(task_text) + + # Show the dialog + dlg.exec() diff --git a/bouquin/reminders.py b/bouquin/reminders.py new file mode 100644 index 0000000..556805d --- /dev/null +++ b/bouquin/reminders.py @@ -0,0 +1,637 @@ +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: + 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, + 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.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder)) + 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/settings.py b/bouquin/settings.py index ad5436d..011d39a 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -43,6 +43,7 @@ def load_db_config() -> DBConfig: 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( @@ -53,6 +54,7 @@ def load_db_config() -> DBConfig: move_todos=move_todos, tags=tags, time_log=time_log, + reminders=reminders, locale=locale, font_size=font_size, ) @@ -67,5 +69,6 @@ def save_db_config(cfg: DBConfig) -> None: 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 8341e8b..90f301d 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -176,6 +176,11 @@ class SettingsDialog(QDialog): self.time_log.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.time_log) + 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 @@ -302,6 +307,7 @@ class SettingsDialog(QDialog): 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(), ) diff --git a/bouquin/table_editor.py b/bouquin/table_editor.py new file mode 100644 index 0000000..100b738 --- /dev/null +++ b/bouquin/table_editor.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import re +from typing import Optional + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QTableWidget, + QTableWidgetItem, + QPushButton, + QHeaderView, + QWidget, +) + +from . import strings + + +class TableEditorDialog(QDialog): + """Dialog for editing markdown tables visually.""" + + def __init__(self, table_text: str, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setWindowTitle(strings._("edit_table")) + self.setMinimumSize(600, 400) + + layout = QVBoxLayout(self) + + # Parse the table + self.table_widget = QTableWidget() + self._parse_table(table_text) + + # Allow editing + self.table_widget.horizontalHeader().setSectionResizeMode( + QHeaderView.Interactive + ) + layout.addWidget(self.table_widget) + + # Buttons for table operations + btn_layout = QHBoxLayout() + + add_row_btn = QPushButton(strings._("add_row")) + add_row_btn.clicked.connect(self._add_row) + btn_layout.addWidget(add_row_btn) + + add_col_btn = QPushButton(strings._("add_column")) + add_col_btn.clicked.connect(self._add_column) + btn_layout.addWidget(add_col_btn) + + del_row_btn = QPushButton(strings._("delete_row")) + del_row_btn.clicked.connect(self._delete_row) + btn_layout.addWidget(del_row_btn) + + del_col_btn = QPushButton(strings._("delete_column")) + del_col_btn.clicked.connect(self._delete_column) + btn_layout.addWidget(del_col_btn) + + layout.addLayout(btn_layout) + + # OK/Cancel buttons + btn_layout2 = QHBoxLayout() + btn_layout2.addStretch() + + ok_btn = QPushButton(strings._("ok")) + ok_btn.clicked.connect(self.accept) + ok_btn.setDefault(True) + btn_layout2.addWidget(ok_btn) + + cancel_btn = QPushButton(strings._("cancel")) + cancel_btn.clicked.connect(self.reject) + btn_layout2.addWidget(cancel_btn) + + layout.addLayout(btn_layout2) + + def _parse_table(self, text: str): + """Parse markdown table into QTableWidget.""" + lines = [line.strip() for line in text.split("\n") if line.strip()] + + if len(lines) < 1: + return + + # Parse header + header_line = lines[0] + # Split by | and remove first/last empty strings from leading/trailing pipes + header_parts = header_line.split("|") + if len(header_parts) > 0 and not header_parts[0].strip(): + header_parts = header_parts[1:] + if len(header_parts) > 0 and not header_parts[-1].strip(): + header_parts = header_parts[:-1] + headers = [cell.strip() for cell in header_parts] + + # Check if line[1] is a separator line (contains ---) + # If not, treat all lines after header as data + start_data_idx = 1 + if len(lines) > 1: + separator_check = lines[1] + # Split by | and remove first/last empty strings + sep_parts = separator_check.split("|") + if len(sep_parts) > 0 and not sep_parts[0].strip(): + sep_parts = sep_parts[1:] + if len(sep_parts) > 0 and not sep_parts[-1].strip(): + sep_parts = sep_parts[:-1] + cells = [cell.strip() for cell in sep_parts] + # Check if this looks like a separator (contains --- or :--: etc) + if cells and all(re.match(r"^:?-+:?$", cell) for cell in cells): + start_data_idx = 2 # Skip separator line + + # Parse data rows + data_rows = [] + for line in lines[start_data_idx:]: + # Split by | and remove first/last empty strings from leading/trailing pipes + parts = line.split("|") + if len(parts) > 0 and not parts[0].strip(): + parts = parts[1:] + if len(parts) > 0 and not parts[-1].strip(): + parts = parts[:-1] + cells = [cell.strip() for cell in parts] + data_rows.append(cells) + + # Set up table + self.table_widget.setColumnCount(len(headers)) + self.table_widget.setHorizontalHeaderLabels(headers) + self.table_widget.setRowCount(len(data_rows)) + + # Populate cells + for row_idx, row_data in enumerate(data_rows): + for col_idx, cell_text in enumerate(row_data): + if col_idx < len(headers): + item = QTableWidgetItem(cell_text) + self.table_widget.setItem(row_idx, col_idx, item) + + @Slot() + def _add_row(self): + """Add a new row to the table.""" + row_count = self.table_widget.rowCount() + self.table_widget.insertRow(row_count) + + # Add empty items + for col in range(self.table_widget.columnCount()): + self.table_widget.setItem(row_count, col, QTableWidgetItem("")) + + @Slot() + def _add_column(self): + """Add a new column to the table.""" + col_count = self.table_widget.columnCount() + self.table_widget.insertColumn(col_count) + self.table_widget.setHorizontalHeaderItem( + col_count, QTableWidgetItem(strings._("column") + f"{col_count + 1}") + ) + + # Add empty items + for row in range(self.table_widget.rowCount()): + self.table_widget.setItem(row, col_count, QTableWidgetItem("")) + + @Slot() + def _delete_row(self): + """Delete the currently selected row.""" + current_row = self.table_widget.currentRow() + if current_row >= 0: + self.table_widget.removeRow(current_row) + + @Slot() + def _delete_column(self): + """Delete the currently selected column.""" + current_col = self.table_widget.currentColumn() + if current_col >= 0: + self.table_widget.removeColumn(current_col) + + def get_markdown_table(self) -> str: + """Convert the table back to markdown format.""" + if self.table_widget.rowCount() == 0 or self.table_widget.columnCount() == 0: + return "" + + lines = [] + + # Header + headers = [] + for col in range(self.table_widget.columnCount()): + header_item = self.table_widget.horizontalHeaderItem(col) + headers.append( + header_item.text() + if header_item + else strings._("column") + f"{col + 1}" + ) + lines.append("| " + " | ".join(headers) + " |") + + # Separator + lines.append("| " + " | ".join(["---"] * len(headers)) + " |") + + # Data rows + for row in range(self.table_widget.rowCount()): + cells = [] + for col in range(self.table_widget.columnCount()): + item = self.table_widget.item(row, col) + cells.append(item.text() if item else "") + lines.append("| " + " | ".join(cells) + " |") + + return "\n".join(lines) + + +def find_table_at_cursor(text: str, cursor_pos: int) -> Optional[tuple[int, int, str]]: + """ + Find a markdown table containing the cursor position. + Returns (start_pos, end_pos, table_text) or None. + """ + lines = text.split("\n") + + # Find which line the cursor is on + current_pos = 0 + cursor_line_idx = 0 + for i, line in enumerate(lines): + if current_pos + len(line) >= cursor_pos: + cursor_line_idx = i + break + current_pos += len(line) + 1 # +1 for newline + + # Check if cursor line is part of a table + if not _is_table_line(lines[cursor_line_idx]): + return None + + # Find table start + start_idx = cursor_line_idx + while start_idx > 0 and _is_table_line(lines[start_idx - 1]): + start_idx -= 1 + + # Find table end + end_idx = cursor_line_idx + while end_idx < len(lines) - 1 and _is_table_line(lines[end_idx + 1]): + end_idx += 1 + + # Extract table text + table_lines = lines[start_idx : end_idx + 1] + table_text = "\n".join(table_lines) + + # Calculate character positions + start_pos = sum(len(lines[i]) + 1 for i in range(start_idx)) + end_pos = start_pos + len(table_text) + + return (start_pos, end_pos, table_text) + + +def _is_table_line(line: str) -> bool: + """Check if a line is part of a markdown table.""" + stripped = line.strip() + if not stripped: + return False + + # Table lines start and end with | + if not (stripped.startswith("|") and stripped.endswith("|")): + return False + + # Must have at least one | in between + return stripped.count("|") >= 2 diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 9ff5da4..163c817 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -274,7 +274,7 @@ class TimeLogDialog(QDialog): # --- Close button close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton("&" + strings._("close")) + close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) @@ -572,7 +572,7 @@ class TimeCodeManagerDialog(QDialog): # Close close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton("&" + strings._("close")) + close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) @@ -916,7 +916,7 @@ class TimeReportDialog(QDialog): # Close close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton("&" + strings._("close")) + close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index a9fb730..cba1820 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -19,6 +19,8 @@ class ToolBar(QToolBar): historyRequested = Signal() insertImageRequested = Signal() alarmRequested = Signal() + timerRequested = Signal() + tableRequested = Signal() fontSizeLargerRequested = Signal() fontSizeSmallerRequested = Signal() @@ -103,6 +105,16 @@ class ToolBar(QToolBar): 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) + + # Table + self.actTable = QAction("⊞", self) + self.actTable.setToolTip(strings._("toolbar_insert_table")) + self.actTable.triggered.connect(self.tableRequested) + # Images self.actInsertImg = QAction("📸", self) self.actInsertImg.setToolTip(strings._("insert_images")) @@ -151,6 +163,8 @@ class ToolBar(QToolBar): self.actNumbers, self.actCheckboxes, self.actAlarm, + self.actTimer, + self.actTable, self.actInsertImg, self.actHistory, ] @@ -177,6 +191,8 @@ class ToolBar(QToolBar): 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, "⌛") + self._style_letter_button(self.actTable, "⊞") # History self._style_letter_button(self.actHistory, "⎌") diff --git a/bouquin/version_check.py b/bouquin/version_check.py index a57e125..07a8a41 100644 --- a/bouquin/version_check.py +++ b/bouquin/version_check.py @@ -408,5 +408,5 @@ class VersionChecker: QMessageBox.information( self._parent, strings._("update"), - strings._("downloaded_and_verified_new_appimage") + appimage_path, + strings._("downloaded_and_verified_new_appimage") + str(appimage_path), ) diff --git a/pyproject.toml b/pyproject.toml index 7e95f82..55a9065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.4.5" +version = "0.5" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/conftest.py b/tests/conftest.py index 445c48e..658b7e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,7 +36,16 @@ def tmp_db_cfg(tmp_path): default_db = tmp_path / "notebook.db" key = "test-secret-key" return DBConfig( - path=default_db, key=key, idle_minutes=0, theme="light", move_todos=True + 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, ) diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py new file mode 100644 index 0000000..145e156 --- /dev/null +++ b/tests/test_code_highlighter.py @@ -0,0 +1,398 @@ +from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata +from PySide6.QtGui import QTextCharFormat, QFont + + +def test_get_language_patterns_python(app): + """Test getting highlighting patterns for Python.""" + patterns = CodeHighlighter.get_language_patterns("python") + + assert len(patterns) > 0 + # Should have comment pattern + assert any("#" in p[0] for p in patterns) + # Should have string patterns + assert any('"' in p[0] for p in patterns) + # Should have keyword patterns + assert any("keyword" == p[1] for p in patterns) + + +def test_get_language_patterns_javascript(app): + """Test getting highlighting patterns for JavaScript.""" + patterns = CodeHighlighter.get_language_patterns("javascript") + + assert len(patterns) > 0 + # Should have // comment pattern + assert any("//" in p[0] for p in patterns) + # Should have /* */ comment pattern (with escaped asterisks in regex) + assert any(r"/\*" in p[0] for p in patterns) + + +def test_get_language_patterns_php(app): + """Test getting highlighting patterns for PHP.""" + patterns = CodeHighlighter.get_language_patterns("php") + + assert len(patterns) > 0 + # Should have # comment pattern + assert any("#" in p[0] for p in patterns) + # Should have // comment pattern + assert any("//" in p[0] for p in patterns) + # Should have /* */ comment pattern (with escaped asterisks in regex) + assert any(r"/\*" in p[0] for p in patterns) + + +def test_get_language_patterns_bash(app): + """Test getting highlighting patterns for Bash.""" + patterns = CodeHighlighter.get_language_patterns("bash") + + assert len(patterns) > 0 + # Should have # comment pattern + assert any("#" in p[0] for p in patterns) + # Should have bash keywords + keyword_patterns = [p for p in patterns if p[1] == "keyword"] + assert len(keyword_patterns) > 0 + + +def test_get_language_patterns_html(app): + """Test getting highlighting patterns for HTML.""" + patterns = CodeHighlighter.get_language_patterns("html") + + assert len(patterns) > 0 + # Should have tag pattern + assert any("tag" == p[1] for p in patterns) + # Should have HTML comment pattern + assert any("" in result + + +def test_code_block_metadata_serialize_sorted(app): + """Test that serialized metadata is sorted by block number.""" + metadata = CodeBlockMetadata() + metadata.set_language(5, "python") + metadata.set_language(2, "javascript") + metadata.set_language(8, "bash") + + result = metadata.serialize() + + # Find positions in string + pos_2 = result.find("2:") + pos_5 = result.find("5:") + pos_8 = result.find("8:") + + # Should be in order + assert pos_2 < pos_5 < pos_8 + + +def test_code_block_metadata_deserialize(app): + """Test deserializing metadata.""" + metadata = CodeBlockMetadata() + text = ( + "Some content\n\nMore content" + ) + + metadata.deserialize(text) + + assert metadata.get_language(0) == "python" + assert metadata.get_language(3) == "javascript" + assert metadata.get_language(5) == "bash" + + +def test_code_block_metadata_deserialize_empty(app): + """Test deserializing from text without metadata.""" + metadata = CodeBlockMetadata() + metadata.set_language(0, "python") # Set some initial data + + text = "Just some regular text with no metadata" + metadata.deserialize(text) + + # Should clear existing data + assert len(metadata._block_languages) == 0 + + +def test_code_block_metadata_deserialize_invalid_format(app): + """Test deserializing with invalid format.""" + metadata = CodeBlockMetadata() + text = "" + + metadata.deserialize(text) + + # Should handle gracefully, resulting in empty or minimal data + # Pairs without ':' should be skipped + assert len(metadata._block_languages) == 0 + + +def test_code_block_metadata_deserialize_invalid_block_number(app): + """Test deserializing with invalid block number.""" + metadata = CodeBlockMetadata() + text = "" + + metadata.deserialize(text) + + # Should skip invalid block number 'abc' + assert metadata.get_language(3) == "javascript" + assert "abc" not in str(metadata._block_languages) + + +def test_code_block_metadata_round_trip(app): + """Test serializing and deserializing preserves data.""" + metadata1 = CodeBlockMetadata() + metadata1.set_language(0, "python") + metadata1.set_language(2, "javascript") + metadata1.set_language(7, "bash") + + serialized = metadata1.serialize() + + metadata2 = CodeBlockMetadata() + metadata2.deserialize(serialized) + + assert metadata2.get_language(0) == "python" + assert metadata2.get_language(2) == "javascript" + assert metadata2.get_language(7) == "bash" + + +def test_python_keywords_present(app): + """Test that Python keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("python", []) + + assert "def" in keywords + assert "class" in keywords + assert "if" in keywords + assert "for" in keywords + assert "import" in keywords + + +def test_javascript_keywords_present(app): + """Test that JavaScript keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("javascript", []) + + assert "function" in keywords + assert "const" in keywords + assert "let" in keywords + assert "var" in keywords + assert "class" in keywords + + +def test_php_keywords_present(app): + """Test that PHP keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("php", []) + + assert "function" in keywords + assert "class" in keywords + assert "echo" in keywords + assert "require" in keywords + + +def test_bash_keywords_present(app): + """Test that Bash keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("bash", []) + + assert "if" in keywords + assert "then" in keywords + assert "fi" in keywords + assert "for" in keywords + + +def test_html_keywords_present(app): + """Test that HTML keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("html", []) + + assert "div" in keywords + assert "span" in keywords + assert "body" in keywords + assert "html" in keywords + + +def test_css_keywords_present(app): + """Test that CSS keywords are defined.""" + keywords = CodeHighlighter.KEYWORDS.get("css", []) + + assert "color" in keywords + assert "background" in keywords + assert "margin" in keywords + assert "padding" in keywords + + +def test_all_patterns_have_string_and_number(app): + """Test that all languages have string and number patterns.""" + languages = ["python", "javascript", "php", "bash", "html", "css"] + + for lang in languages: + patterns = CodeHighlighter.get_language_patterns(lang) + pattern_types = [p[1] for p in patterns] + + assert "string" in pattern_types, f"{lang} should have string pattern" + assert "number" in pattern_types, f"{lang} should have number pattern" + + +def test_patterns_have_regex_format(app): + """Test that patterns are in regex format.""" + patterns = CodeHighlighter.get_language_patterns("python") + + for pattern, pattern_type in patterns: + # Each pattern should be a string (regex pattern) + assert isinstance(pattern, str) + # Each type should be a string + assert isinstance(pattern_type, str) + + +def test_code_block_metadata_update_language(app): + """Test updating language for existing block.""" + metadata = CodeBlockMetadata() + + metadata.set_language(0, "python") + assert metadata.get_language(0) == "python" + + metadata.set_language(0, "javascript") + assert metadata.get_language(0) == "javascript" + + +def test_get_format_preserves_base_format_properties(app): + """Test that get_format_for_type preserves base format properties.""" + base_format = QTextCharFormat() + base_format.setFontPointSize(14) + + fmt = CodeHighlighter.get_format_for_type("keyword", base_format) + + # Should be based on the base_format + assert isinstance(fmt, QTextCharFormat) diff --git a/tests/test_main_window.py b/tests/test_main_window.py index bbf7f0a..dd4932f 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -25,6 +25,11 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): 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) diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py new file mode 100644 index 0000000..9d34a4f --- /dev/null +++ b/tests/test_pomodoro_timer.py @@ -0,0 +1,354 @@ +from unittest.mock import Mock, patch +from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager + + +def test_pomodoro_timer_init(qtbot, app, fresh_db): + """Test PomodoroTimer initialization.""" + task_text = "Write unit tests" + timer = PomodoroTimer(task_text) + qtbot.addWidget(timer) + + assert timer._task_text == task_text + assert timer._elapsed_seconds == 0 + assert timer._running is False + assert timer.time_label.text() == "00:00:00" + assert timer.stop_btn.isEnabled() is False + + +def test_pomodoro_timer_start(qtbot, app): + """Test starting the timer.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + timer._toggle_timer() + + assert timer._running is True + assert timer.stop_btn.isEnabled() is True + + +def test_pomodoro_timer_pause(qtbot, app): + """Test pausing the timer.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + # Start the timer + timer._toggle_timer() + assert timer._running is True + + # Pause the timer + timer._toggle_timer() + assert timer._running is False + + +def test_pomodoro_timer_resume(qtbot, app): + """Test resuming the timer after pause.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + # Start, pause, then resume + timer._toggle_timer() # Start + timer._toggle_timer() # Pause + timer._toggle_timer() # Resume + + assert timer._running is True + + +def test_pomodoro_timer_tick(qtbot, app): + """Test timer tick increments elapsed time.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + initial_time = timer._elapsed_seconds + timer._tick() + + assert timer._elapsed_seconds == initial_time + 1 + + +def test_pomodoro_timer_display_update(qtbot, app): + """Test display updates with various elapsed times.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + # Test 0 seconds + timer._elapsed_seconds = 0 + timer._update_display() + assert timer.time_label.text() == "00:00:00" + + # Test 65 seconds (1 min 5 sec) + timer._elapsed_seconds = 65 + timer._update_display() + assert timer.time_label.text() == "00:01:05" + + # Test 3665 seconds (1 hour 1 min 5 sec) + timer._elapsed_seconds = 3665 + timer._update_display() + assert timer.time_label.text() == "01:01:05" + + # Test 3600 seconds (1 hour exactly) + timer._elapsed_seconds = 3600 + timer._update_display() + assert timer.time_label.text() == "01:00:00" + + +def test_pomodoro_timer_stop_and_log_running(qtbot, app): + """Test stopping the timer while it's running.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + # Start the timer + timer._toggle_timer() + timer._elapsed_seconds = 100 + + # Connect a mock to the signal + signal_received = [] + timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) + + timer._stop_and_log() + + assert timer._running is False + assert len(signal_received) == 1 + assert signal_received[0][0] == 100 # elapsed seconds + assert signal_received[0][1] == "Test task" + + +def test_pomodoro_timer_stop_and_log_paused(qtbot, app): + """Test stopping the timer when it's paused.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + timer._elapsed_seconds = 50 + + signal_received = [] + timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) + + timer._stop_and_log() + + assert len(signal_received) == 1 + assert signal_received[0][0] == 50 + + +def test_pomodoro_timer_multiple_ticks(qtbot, app): + """Test multiple timer ticks.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + for i in range(10): + timer._tick() + + assert timer._elapsed_seconds == 10 + assert "00:00:10" in timer.time_label.text() + + +def test_pomodoro_timer_modal_state(qtbot, app): + """Test that timer is non-modal.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + assert timer.isModal() is False + + +def test_pomodoro_timer_window_title(qtbot, app): + """Test timer window title.""" + timer = PomodoroTimer("Test task") + qtbot.addWidget(timer) + + # Window title should contain some reference to timer/pomodoro + assert len(timer.windowTitle()) > 0 + + +def test_pomodoro_manager_init(app, fresh_db): + """Test PomodoroManager initialization.""" + parent = Mock() + manager = PomodoroManager(fresh_db, parent) + + assert manager._db is fresh_db + assert manager._parent is parent + assert manager._active_timer is None + + +def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): + """Test starting a timer through the manager.""" + from PySide6.QtWidgets import QWidget + + parent = QWidget() + qtbot.addWidget(parent) + manager = PomodoroManager(fresh_db, parent) + + line_text = "Important task" + date_iso = "2024-01-15" + + manager.start_timer_for_line(line_text, date_iso) + + assert manager._active_timer is not None + assert manager._active_timer._task_text == line_text + qtbot.addWidget(manager._active_timer) + + +def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): + """Test that starting a new timer closes the previous one.""" + from PySide6.QtWidgets import QWidget + + parent = QWidget() + qtbot.addWidget(parent) + manager = PomodoroManager(fresh_db, parent) + + # Start first timer + manager.start_timer_for_line("Task 1", "2024-01-15") + first_timer = manager._active_timer + qtbot.addWidget(first_timer) + first_timer.show() + + # Start second timer + manager.start_timer_for_line("Task 2", "2024-01-16") + second_timer = manager._active_timer + qtbot.addWidget(second_timer) + + assert first_timer is not second_timer + assert second_timer._task_text == "Task 2" + + +def test_pomodoro_manager_on_timer_stopped_minimum_hours( + qtbot, app, fresh_db, monkeypatch +): + """Test that timer stopped with very short time logs minimum hours.""" + parent = Mock() + manager = PomodoroManager(fresh_db, parent) + + # Mock TimeLogDialog to avoid actually showing it + mock_dialog = Mock() + mock_dialog.hours_spin = Mock() + mock_dialog.note = Mock() + mock_dialog.exec = Mock() + + with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): + manager._on_timer_stopped(10, "Quick task", "2024-01-15") + + # Should set minimum of 0.25 hours + mock_dialog.hours_spin.setValue.assert_called_once() + hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] + assert hours_set >= 0.25 + + +def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): + """Test that elapsed time is properly rounded to decimal hours.""" + parent = Mock() + manager = PomodoroManager(fresh_db, parent) + + mock_dialog = Mock() + mock_dialog.hours_spin = Mock() + mock_dialog.note = Mock() + mock_dialog.exec = Mock() + + with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): + # Test with 1800 seconds (30 minutes) + manager._on_timer_stopped(1800, "Task", "2024-01-15") + + mock_dialog.hours_spin.setValue.assert_called_once() + hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] + # Should round up and be a multiple of 0.25 + assert hours_set > 0 + assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25 + + +def test_pomodoro_manager_on_timer_stopped_prefills_note( + qtbot, app, fresh_db, monkeypatch +): + """Test that timer stopped pre-fills the note in time log dialog.""" + parent = Mock() + manager = PomodoroManager(fresh_db, parent) + + mock_dialog = Mock() + mock_dialog.hours_spin = Mock() + mock_dialog.note = Mock() + mock_dialog.exec = Mock() + + task_text = "Write documentation" + + with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): + manager._on_timer_stopped(3600, task_text, "2024-01-15") + + mock_dialog.note.setText.assert_called_once_with(task_text) + + +def test_pomodoro_manager_timer_stopped_signal_connection( + qtbot, app, fresh_db, monkeypatch +): + """Test that timer stopped signal is properly connected.""" + from PySide6.QtWidgets import QWidget + + parent = QWidget() + qtbot.addWidget(parent) + manager = PomodoroManager(fresh_db, parent) + + # Mock TimeLogDialog + mock_dialog = Mock() + mock_dialog.hours_spin = Mock() + mock_dialog.note = Mock() + mock_dialog.exec = Mock() + + with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): + manager.start_timer_for_line("Task", "2024-01-15") + timer = manager._active_timer + qtbot.addWidget(timer) + + # Simulate timer stopped + timer._elapsed_seconds = 1000 + timer._stop_and_log() + + # TimeLogDialog should have been created + assert mock_dialog.exec.called + + +def test_pomodoro_timer_accepts_parent(qtbot, app): + """Test that timer accepts a parent widget.""" + from PySide6.QtWidgets import QWidget + + parent = QWidget() + qtbot.addWidget(parent) + timer = PomodoroTimer("Task", parent) + qtbot.addWidget(timer) + + assert timer.parent() is parent + + +def test_pomodoro_manager_no_active_timer_initially(app, fresh_db): + """Test that manager starts with no active timer.""" + parent = Mock() + manager = PomodoroManager(fresh_db, parent) + + assert manager._active_timer is None + + +def test_pomodoro_timer_start_stop_cycle(qtbot, app): + """Test a complete start-stop cycle.""" + timer = PomodoroTimer("Complete cycle") + qtbot.addWidget(timer) + + signal_received = [] + timer.timerStopped.connect(lambda s, t: signal_received.append((s, t))) + + # Start + timer._toggle_timer() + assert timer._running is True + + # Simulate some ticks + for _ in range(5): + timer._tick() + + # Stop + timer._stop_and_log() + assert timer._running is False + assert len(signal_received) == 1 + assert signal_received[0][0] == 5 + + +def test_pomodoro_timer_long_elapsed_time(qtbot, app): + """Test display with very long elapsed time.""" + timer = PomodoroTimer("Long task") + qtbot.addWidget(timer) + + # Set to 2 hours, 34 minutes, 56 seconds + timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56 + timer._update_display() + + assert timer.time_label.text() == "02:34:56" diff --git a/tests/test_reminders.py b/tests/test_reminders.py new file mode 100644 index 0000000..e05af64 --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,657 @@ +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 + + +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() diff --git a/tests/test_settings.py b/tests/test_settings.py index 5654193..f272ab2 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -15,7 +15,11 @@ def _clear_db_settings(): "ui/idle_minutes", "ui/theme", "ui/move_todos", + "ui/tags", + "ui/time_log", + "ui/reminders", "ui/locale", + "ui/font_size", ]: s.remove(k) @@ -29,7 +33,11 @@ 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) @@ -39,7 +47,11 @@ 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): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 50a5751..ad53951 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -22,6 +22,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): 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(): @@ -39,6 +40,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): 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") diff --git a/tests/test_table_editor.py b/tests/test_table_editor.py new file mode 100644 index 0000000..ac6887f --- /dev/null +++ b/tests/test_table_editor.py @@ -0,0 +1,384 @@ +from bouquin.table_editor import TableEditorDialog, find_table_at_cursor, _is_table_line + + +def test_table_editor_init_simple_table(qtbot, app): + """Test initialization with a simple markdown table.""" + table_text = """| Header1 | Header2 | Header3 | +| --- | --- | --- | +| Cell1 | Cell2 | Cell3 | +| Cell4 | Cell5 | Cell6 |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + assert dialog.table_widget.columnCount() == 3 + assert dialog.table_widget.rowCount() == 2 + assert dialog.table_widget.horizontalHeaderItem(0).text() == "Header1" + assert dialog.table_widget.horizontalHeaderItem(1).text() == "Header2" + assert dialog.table_widget.item(0, 0).text() == "Cell1" + assert dialog.table_widget.item(1, 2).text() == "Cell6" + + +def test_table_editor_no_separator_line(qtbot, app): + """Test parsing table without separator line.""" + table_text = """| Header1 | Header2 | +| Cell1 | Cell2 | +| Cell3 | Cell4 |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + assert dialog.table_widget.columnCount() == 2 + assert dialog.table_widget.rowCount() == 2 + assert dialog.table_widget.item(0, 0).text() == "Cell1" + + +def test_table_editor_empty_table(qtbot, app): + """Test initialization with empty table text.""" + dialog = TableEditorDialog("") + qtbot.addWidget(dialog) + + # Should have no columns/rows + assert dialog.table_widget.columnCount() == 0 or dialog.table_widget.rowCount() == 0 + + +def test_table_editor_single_header_line(qtbot, app): + """Test table with only header line.""" + table_text = "| Header1 | Header2 | Header3 |" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + assert dialog.table_widget.columnCount() == 3 + assert dialog.table_widget.rowCount() == 0 + + +def test_add_row(qtbot, app): + """Test adding a row to the table.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_rows = dialog.table_widget.rowCount() + dialog._add_row() + + assert dialog.table_widget.rowCount() == initial_rows + 1 + # New row should have empty items + assert dialog.table_widget.item(initial_rows, 0).text() == "" + assert dialog.table_widget.item(initial_rows, 1).text() == "" + + +def test_add_column(qtbot, app): + """Test adding a column to the table.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_cols = dialog.table_widget.columnCount() + dialog._add_column() + + assert dialog.table_widget.columnCount() == initial_cols + 1 + # New column should have header and empty items + assert "Column" in dialog.table_widget.horizontalHeaderItem(initial_cols).text() + assert dialog.table_widget.item(0, initial_cols).text() == "" + + +def test_delete_row(qtbot, app): + """Test deleting a row from the table.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B | +| C | D |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_rows = dialog.table_widget.rowCount() + dialog.table_widget.setCurrentCell(0, 0) + dialog._delete_row() + + assert dialog.table_widget.rowCount() == initial_rows - 1 + + +def test_delete_row_no_selection(qtbot, app): + """Test deleting a row when nothing is selected.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_rows = dialog.table_widget.rowCount() + dialog.table_widget.setCurrentCell(-1, -1) # No selection + dialog._delete_row() + + # Row count should remain the same + assert dialog.table_widget.rowCount() == initial_rows + + +def test_delete_column(qtbot, app): + """Test deleting a column from the table.""" + table_text = """| H1 | H2 | H3 | +| --- | --- | --- | +| A | B | C |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_cols = dialog.table_widget.columnCount() + dialog.table_widget.setCurrentCell(0, 1) + dialog._delete_column() + + assert dialog.table_widget.columnCount() == initial_cols - 1 + + +def test_delete_column_no_selection(qtbot, app): + """Test deleting a column when nothing is selected.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + initial_cols = dialog.table_widget.columnCount() + dialog.table_widget.setCurrentCell(-1, -1) # No selection + dialog._delete_column() + + # Column count should remain the same + assert dialog.table_widget.columnCount() == initial_cols + + +def test_get_markdown_table(qtbot, app): + """Test converting table back to markdown.""" + table_text = """| Name | Age | City | +| --- | --- | --- | +| Alice | 30 | NYC | +| Bob | 25 | LA |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + result = dialog.get_markdown_table() + + assert "| Name | Age | City |" in result + assert "| --- | --- | --- |" in result + assert "| Alice | 30 | NYC |" in result + assert "| Bob | 25 | LA |" in result + + +def test_get_markdown_table_empty(qtbot, app): + """Test getting markdown from empty table.""" + dialog = TableEditorDialog("") + qtbot.addWidget(dialog) + + result = dialog.get_markdown_table() + assert result == "" + + +def test_get_markdown_table_with_modifications(qtbot, app): + """Test getting markdown after modifying table.""" + table_text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + # Modify a cell + dialog.table_widget.item(0, 0).setText("Modified") + + result = dialog.get_markdown_table() + assert "Modified" in result + + +def test_find_table_at_cursor_middle_of_table(qtbot, app): + """Test finding table when cursor is in the middle.""" + text = """Some text before + +| H1 | H2 | +| --- | --- | +| A | B | +| C | D | + +Some text after""" + + # Cursor position in the middle of the table + cursor_pos = text.find("| A |") + 2 + result = find_table_at_cursor(text, cursor_pos) + + assert result is not None + start, end, table_text = result + assert "| H1 | H2 |" in table_text + assert "| A | B |" in table_text + assert "Some text before" not in table_text + assert "Some text after" not in table_text + + +def test_find_table_at_cursor_first_line(qtbot, app): + """Test finding table when cursor is on the first line.""" + text = """| H1 | H2 | +| --- | --- | +| A | B |""" + + cursor_pos = 5 # In the first line + result = find_table_at_cursor(text, cursor_pos) + + assert result is not None + start, end, table_text = result + assert "| H1 | H2 |" in table_text + + +def test_find_table_at_cursor_not_in_table(qtbot, app): + """Test finding table when cursor is not in a table.""" + text = """Just some regular text +No tables here + +| H1 | H2 | +| --- | --- | +| A | B |""" + + cursor_pos = 10 # In "Just some regular text" + result = find_table_at_cursor(text, cursor_pos) + + assert result is None + + +def test_find_table_at_cursor_empty_text(qtbot, app): + """Test finding table in empty text.""" + result = find_table_at_cursor("", 0) + assert result is None + + +def test_find_table_at_cursor_multiple_tables(qtbot, app): + """Test finding correct table when there are multiple tables.""" + text = """| Table1 | H1 | +| --- | --- | + +Some text + +| Table2 | H2 | +| --- | --- | +| Data | Here |""" + + # Cursor in second table + cursor_pos = text.find("| Data |") + result = find_table_at_cursor(text, cursor_pos) + + assert result is not None + start, end, table_text = result + assert "Table2" in table_text + assert "Table1" not in table_text + + +def test_is_table_line_valid(qtbot, app): + """Test identifying valid table lines.""" + assert _is_table_line("| Header | Header2 |") is True + assert _is_table_line("| --- | --- |") is True + assert _is_table_line("| Cell | Cell2 | Cell3 |") is True + + +def test_is_table_line_invalid(qtbot, app): + """Test identifying invalid table lines.""" + assert _is_table_line("Just regular text") is False + assert _is_table_line("") is False + assert _is_table_line(" ") is False + assert _is_table_line("| Only one pipe") is False + assert _is_table_line("Only one pipe |") is False + assert _is_table_line("No pipes at all") is False + + +def test_is_table_line_edge_cases(qtbot, app): + """Test edge cases for table line detection.""" + assert _is_table_line("| | |") is True # Minimal valid table + assert ( + _is_table_line(" | Header | Data | ") is True + ) # With leading/trailing spaces + + +def test_table_with_alignment_indicators(qtbot, app): + """Test parsing table with alignment indicators.""" + table_text = """| Left | Center | Right | +| :--- | :---: | ---: | +| L1 | C1 | R1 |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + assert dialog.table_widget.columnCount() == 3 + assert dialog.table_widget.rowCount() == 1 + assert dialog.table_widget.item(0, 0).text() == "L1" + + +def test_accept_dialog(qtbot, app): + """Test accepting the dialog.""" + table_text = "| H1 | H2 |\n| --- | --- |\n| A | B |" + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + # Find and click the OK button + for child in dialog.findChildren(type(dialog.findChild(type(None)))): + if hasattr(child, "text") and callable(child.text): + try: + if "ok" in child.text().lower() or "OK" in child.text(): + child.click() + break + except: + pass + + +def test_reject_dialog(qtbot, app): + """Test rejecting the dialog.""" + table_text = "| H1 | H2 |\n| --- | --- |\n| A | B |" + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + # Find and click the Cancel button + for child in dialog.findChildren(type(dialog.findChild(type(None)))): + if hasattr(child, "text") and callable(child.text): + try: + if "cancel" in child.text().lower(): + child.click() + break + except: + pass + + +def test_table_with_uneven_columns(qtbot, app): + """Test parsing table with uneven number of columns in rows.""" + table_text = """| H1 | H2 | H3 | +| --- | --- | --- | +| A | B | +| C | D | E | F |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + # Should handle gracefully + assert dialog.table_widget.columnCount() == 3 + assert dialog.table_widget.rowCount() == 2 + + +def test_table_with_empty_cells(qtbot, app): + """Test parsing table with empty cells.""" + table_text = """| H1 | H2 | H3 | +| --- | --- | --- | +| | B | | +| C | | E |""" + + dialog = TableEditorDialog(table_text) + qtbot.addWidget(dialog) + + assert dialog.table_widget.item(0, 0).text() == "" + assert dialog.table_widget.item(0, 1).text() == "B" + assert dialog.table_widget.item(0, 2).text() == "" + assert dialog.table_widget.item(1, 0).text() == "C" + assert dialog.table_widget.item(1, 1).text() == "" + assert dialog.table_widget.item(1, 2).text() == "E" diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..87a2068 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,512 @@ +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() diff --git a/vulture_ignorelist.py b/vulture_ignorelist.py index c4ea333..2addc55 100644 --- a/vulture_ignorelist.py +++ b/vulture_ignorelist.py @@ -18,6 +18,7 @@ MarkdownEditor.apply_italic MarkdownEditor.apply_strikethrough MarkdownEditor.apply_code MarkdownEditor.apply_heading +MarkdownEditor.contextMenuEvent MarkdownEditor.toggle_bullets MarkdownEditor.toggle_numbers MarkdownEditor.toggle_checkboxes