Many changes and new features:
All checks were successful
CI / test (push) Successful in 5m17s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s

* 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
This commit is contained in:
Miguel Jacq 2025-11-25 14:52:26 +11:00
parent 26737fbfb2
commit e0169db52a
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
28 changed files with 4191 additions and 17 deletions

View file

@ -1,8 +1,14 @@
# 0.4.6 # 0.5
* More Italian translations, thank you @mdaleo404 * More Italian translations, thank you @mdaleo404
* Set locked status on window title when locked * Set locked status on window title when locked
* Don't exit on incorrect key, let it be tried again * 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 # 0.4.5

365
bouquin/code_highlighter.py Normal file
View file

@ -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 "<!-- code-langs: " + ",".join(items) + " -->"
def deserialize(self, text: str):
"""Deserialize metadata from text."""
self._block_languages.clear()
# Look for metadata comment at the end
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
if match:
pairs = match.group(1).split(",")
for pair in pairs:
if ":" in pair:
block_num, lang = pair.split(":", 1)
try:
self._block_languages[int(block_num)] = lang
except ValueError:
pass

View file

@ -63,6 +63,7 @@ class DBConfig:
move_todos: bool = False move_todos: bool = False
tags: bool = True tags: bool = True
time_log: bool = True time_log: bool = True
reminders: bool = True
locale: str = "en" locale: str = "en"
font_size: int = 11 font_size: int = 11
@ -195,6 +196,20 @@ class DBManager:
ON time_log(project_id); ON time_log(project_id);
CREATE INDEX IF NOT EXISTS ix_time_log_activity CREATE INDEX IF NOT EXISTS ix_time_log_activity
ON time_log(activity_id); 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() self.conn.commit()
@ -1015,3 +1030,90 @@ class DBManager:
if self.conn is not None: if self.conn is not None:
self.conn.close() self.conn.close()
self.conn = None 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()

View file

@ -49,6 +49,9 @@
"backup_complete": "Backup complete", "backup_complete": "Backup complete",
"backup_failed": "Backup failed", "backup_failed": "Backup failed",
"quit": "Quit", "quit": "Quit",
"cancel": "Cancel",
"ok": "OK",
"save": "Save",
"help": "Help", "help": "Help",
"saved": "Saved", "saved": "Saved",
"saved_to": "Saved to", "saved_to": "Saved to",
@ -256,5 +259,44 @@
"export_pdf_error_title": "PDF export failed", "export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}", "export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags", "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"
} }

View file

@ -121,5 +121,16 @@
"change_color": "Changer la couleur", "change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette", "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.", "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"
} }

View file

@ -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_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", "bug_report_placeholder": "Scrivi la tua segnalazione qui",
"update": "Aggiornamento", "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"
} }

View file

@ -57,6 +57,8 @@ from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .save_dialog import SaveDialog from .save_dialog import SaveDialog
from .search import Search from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config 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.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed) self.search.resultDatesChanged.connect(self._on_search_dates_changed)
# Features
self.time_log = TimeLogWidget(self.db) self.time_log = TimeLogWidget(self.db)
self.tags = PageTagsWidget(self.db) self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated) self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added) 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 # Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized. # when the main window is resized.
left_panel = QWidget() left_panel = QWidget()
@ -119,6 +127,7 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar) left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search) left_layout.addWidget(self.search)
left_layout.addWidget(self.upcoming_reminders)
left_layout.addWidget(self.time_log) left_layout.addWidget(self.time_log)
left_layout.addWidget(self.tags) left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
@ -324,6 +333,10 @@ class MainWindow(QMainWindow):
self.tags.hide() self.tags.hide()
if not self.cfg.time_log: if not self.cfg.time_log:
self.time_log.hide() 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 # Restore window position from settings
self._restore_window_position() self._restore_window_position()
@ -1087,6 +1100,8 @@ class MainWindow(QMainWindow):
self._tb_numbers = lambda: self._call_editor("toggle_numbers") self._tb_numbers = lambda: self._call_editor("toggle_numbers")
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes") self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
self._tb_alarm = self._on_alarm_requested 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_larger = self._on_font_larger_requested
self._tb_font_smaller = self._on_font_smaller_requested self._tb_font_smaller = self._on_font_smaller_requested
@ -1099,6 +1114,8 @@ class MainWindow(QMainWindow):
tb.numbersRequested.connect(self._tb_numbers) tb.numbersRequested.connect(self._tb_numbers)
tb.checkboxesRequested.connect(self._tb_checkboxes) tb.checkboxesRequested.connect(self._tb_checkboxes)
tb.alarmRequested.connect(self._tb_alarm) 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.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history) tb.historyRequested.connect(self._open_history)
tb.fontSizeLargerRequested.connect(self._tb_font_larger) tb.fontSizeLargerRequested.connect(self._tb_font_larger)
@ -1228,6 +1245,23 @@ class MainWindow(QMainWindow):
# Rebuild timers, but only if this page is for "today" # Rebuild timers, but only if this page is for "today"
self._rebuild_reminders_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): def _show_flashing_reminder(self, text: str):
""" """
Show a small flashing dialog and request attention from the OS. Show a small flashing dialog and request attention from the OS.
@ -1344,6 +1378,36 @@ class MainWindow(QMainWindow):
timer.start(msecs) timer.start(msecs)
self._reminder_timers.append(timer) 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 ------------# # ----------- History handler ------------#
def _open_history(self): def _open_history(self):
if hasattr(self.editor, "current_date"): 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.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) 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.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.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) 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() self.tags.hide() if not self.cfg.tags else self.tags.show()
if not self.cfg.time_log: if not self.cfg.time_log:
self.time_log.hide() self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
else: else:
self.time_log.show() 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 --------------- # # ------------ Statistics handler --------------- #

View file

@ -22,6 +22,7 @@ from PySide6.QtWidgets import QTextEdit
from .theme import ThemeManager from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter from .markdown_highlighter import MarkdownHighlighter
from . import strings
class MarkdownEditor(QTextEdit): class MarkdownEditor(QTextEdit):
@ -63,7 +64,12 @@ class MarkdownEditor(QTextEdit):
self._BULLET_STORAGE = "-" self._BULLET_STORAGE = "-"
# Install syntax highlighter # 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 # Track current list type for smart enter handling
self._last_enter_was_empty = False self._last_enter_was_empty = False
@ -91,7 +97,9 @@ class MarkdownEditor(QTextEdit):
# Recreate the highlighter for the new document # Recreate the highlighter for the new document
# (the old one gets deleted with the old document) # (the old one gets deleted with the old document)
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"): 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_line_spacing()
self._apply_code_block_spacing() self._apply_code_block_spacing()
QTimer.singleShot(0, self._update_code_block_row_backgrounds) QTimer.singleShot(0, self._update_code_block_row_backgrounds)
@ -274,6 +282,12 @@ class MarkdownEditor(QTextEdit):
text, 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 return text
def _extract_images_to_markdown(self) -> str: def _extract_images_to_markdown(self) -> str:
@ -312,6 +326,16 @@ class MarkdownEditor(QTextEdit):
def from_markdown(self, markdown_text: str): def from_markdown(self, markdown_text: str):
"""Load markdown text into the editor.""" """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*<!-- code-langs: [^>]+ -->\s*$", "", markdown_text)
# Convert markdown checkboxes to Unicode for display # Convert markdown checkboxes to Unicode for display
display_text = markdown_text.replace( display_text = markdown_text.replace(
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} " f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
@ -432,10 +456,6 @@ class MarkdownEditor(QTextEdit):
cursor.select(QTextCursor.SelectionType.LineUnderCursor) cursor.select(QTextCursor.SelectionType.LineUnderCursor)
return cursor.selectedText() 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: def _list_prefix_length_for_block(self, block) -> int:
"""Return the length (in chars) of the visual list prefix for the given """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. block (including leading indentation), or 0 if it's not a list item.
@ -1218,3 +1238,114 @@ class MarkdownEditor(QTextEdit):
cursor = self.textCursor() cursor = self.textCursor()
cursor.insertImage(img_format) cursor.insertImage(img_format)
cursor.insertText("\n") # Add newline after image 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()

View file

@ -19,9 +19,12 @@ from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter): class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type.""" """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) super().__init__(document)
self.theme_manager = theme_manager self.theme_manager = theme_manager
self._editor = editor # Reference to the MarkdownEditor
self._setup_formats() self._setup_formats()
# Recompute formats whenever the app theme changes # Recompute formats whenever the app theme changes
self.theme_manager.themeChanged.connect(self._on_theme_changed) self.theme_manager.themeChanged.connect(self._on_theme_changed)
@ -149,6 +152,36 @@ class MarkdownHighlighter(QSyntaxHighlighter):
if in_code_block: if in_code_block:
# inside code: apply block bg and language rules # inside code: apply block bg and language rules
self.setFormat(0, len(text), self.code_block_format) 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) self.setCurrentBlockState(1)
return return

149
bouquin/pomodoro_timer.py Normal file
View file

@ -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()

637
bouquin/reminders.py Normal file
View file

@ -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()

View file

@ -43,6 +43,7 @@ def load_db_config() -> DBConfig:
move_todos = s.value("ui/move_todos", False, type=bool) move_todos = s.value("ui/move_todos", False, type=bool)
tags = s.value("ui/tags", True, type=bool) tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", 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) locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int) font_size = s.value("ui/font_size", 11, type=int)
return DBConfig( return DBConfig(
@ -53,6 +54,7 @@ def load_db_config() -> DBConfig:
move_todos=move_todos, move_todos=move_todos,
tags=tags, tags=tags,
time_log=time_log, time_log=time_log,
reminders=reminders,
locale=locale, locale=locale,
font_size=font_size, 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/move_todos", str(cfg.move_todos))
s.setValue("ui/tags", str(cfg.tags)) s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log)) 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/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size)) s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -176,6 +176,11 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor) self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log) 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.addWidget(features_group)
layout.addStretch() layout.addStretch()
return page return page
@ -302,6 +307,7 @@ class SettingsDialog(QDialog):
move_todos=self.move_todos.isChecked(), move_todos=self.move_todos.isChecked(),
tags=self.tags.isChecked(), tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(), time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
locale=self.locale_combobox.currentText(), locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(), font_size=self.font_size.value(),
) )

255
bouquin/table_editor.py Normal file
View file

@ -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

View file

@ -274,7 +274,7 @@ class TimeLogDialog(QDialog):
# --- Close button # --- Close button
close_row = QHBoxLayout() close_row = QHBoxLayout()
close_row.addStretch(1) close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn) close_row.addWidget(close_btn)
root.addLayout(close_row) root.addLayout(close_row)
@ -572,7 +572,7 @@ class TimeCodeManagerDialog(QDialog):
# Close # Close
close_row = QHBoxLayout() close_row = QHBoxLayout()
close_row.addStretch(1) close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn) close_row.addWidget(close_btn)
root.addLayout(close_row) root.addLayout(close_row)
@ -916,7 +916,7 @@ class TimeReportDialog(QDialog):
# Close # Close
close_row = QHBoxLayout() close_row = QHBoxLayout()
close_row.addStretch(1) close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn) close_row.addWidget(close_btn)
root.addLayout(close_row) root.addLayout(close_row)

View file

@ -19,6 +19,8 @@ class ToolBar(QToolBar):
historyRequested = Signal() historyRequested = Signal()
insertImageRequested = Signal() insertImageRequested = Signal()
alarmRequested = Signal() alarmRequested = Signal()
timerRequested = Signal()
tableRequested = Signal()
fontSizeLargerRequested = Signal() fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal() fontSizeSmallerRequested = Signal()
@ -103,6 +105,16 @@ class ToolBar(QToolBar):
self.actAlarm.setToolTip(strings._("toolbar_alarm")) self.actAlarm.setToolTip(strings._("toolbar_alarm"))
self.actAlarm.triggered.connect(self.alarmRequested) 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 # Images
self.actInsertImg = QAction("📸", self) self.actInsertImg = QAction("📸", self)
self.actInsertImg.setToolTip(strings._("insert_images")) self.actInsertImg.setToolTip(strings._("insert_images"))
@ -151,6 +163,8 @@ class ToolBar(QToolBar):
self.actNumbers, self.actNumbers,
self.actCheckboxes, self.actCheckboxes,
self.actAlarm, self.actAlarm,
self.actTimer,
self.actTable,
self.actInsertImg, self.actInsertImg,
self.actHistory, self.actHistory,
] ]
@ -177,6 +191,8 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actNumbers, "1.") self._style_letter_button(self.actNumbers, "1.")
self._style_letter_button(self.actCheckboxes, "") self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "") self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actTable, "")
# History # History
self._style_letter_button(self.actHistory, "") self._style_letter_button(self.actHistory, "")

View file

@ -408,5 +408,5 @@ class VersionChecker:
QMessageBox.information( QMessageBox.information(
self._parent, self._parent,
strings._("update"), strings._("update"),
strings._("downloaded_and_verified_new_appimage") + appimage_path, strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
) )

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.4.5" version = "0.5"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

View file

@ -36,7 +36,16 @@ def tmp_db_cfg(tmp_path):
default_db = tmp_path / "notebook.db" default_db = tmp_path / "notebook.db"
key = "test-secret-key" key = "test-secret-key"
return DBConfig( 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,
) )

View file

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

View file

@ -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/idle_minutes", 0)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True) 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)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes) w = MainWindow(themes=themes)

View file

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

657
tests/test_reminders.py Normal file
View file

@ -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()

View file

@ -15,7 +15,11 @@ def _clear_db_settings():
"ui/idle_minutes", "ui/idle_minutes",
"ui/theme", "ui/theme",
"ui/move_todos", "ui/move_todos",
"ui/tags",
"ui/time_log",
"ui/reminders",
"ui/locale", "ui/locale",
"ui/font_size",
]: ]:
s.remove(k) s.remove(k)
@ -29,7 +33,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
idle_minutes=7, idle_minutes=7,
theme="dark", theme="dark",
move_todos=True, move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en", locale="en",
font_size=11,
) )
save_db_config(cfg) 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.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos 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.locale == cfg.locale
assert loaded.font_size == cfg.font_size
def test_load_db_config_migrates_legacy_db_path(app, tmp_path): def test_load_db_config_migrates_legacy_db_path(app, tmp_path):

View file

@ -22,6 +22,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
dlg.move_todos.setChecked(True) dlg.move_todos.setChecked(True)
dlg.tags.setChecked(False) dlg.tags.setChecked(False)
dlg.time_log.setChecked(False) dlg.time_log.setChecked(False)
dlg.reminders.setChecked(False)
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows # Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
def _auto_accept_msgbox(): 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.move_todos is True
assert cfg.tags is False assert cfg.tags is False
assert cfg.time_log is False assert cfg.time_log is False
assert cfg.reminders is False
assert cfg.theme in ("light", "dark", "system") assert cfg.theme in ("light", "dark", "system")

384
tests/test_table_editor.py Normal file
View file

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

512
tests/test_version_check.py Normal file
View file

@ -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()

View file

@ -18,6 +18,7 @@ MarkdownEditor.apply_italic
MarkdownEditor.apply_strikethrough MarkdownEditor.apply_strikethrough
MarkdownEditor.apply_code MarkdownEditor.apply_code
MarkdownEditor.apply_heading MarkdownEditor.apply_heading
MarkdownEditor.contextMenuEvent
MarkdownEditor.toggle_bullets MarkdownEditor.toggle_bullets
MarkdownEditor.toggle_numbers MarkdownEditor.toggle_numbers
MarkdownEditor.toggle_checkboxes MarkdownEditor.toggle_checkboxes