Many changes and new features:
* Make reminders be its own dataset rather than tied to current string. * Add support for repeated reminders * Make reminders be a feature that can be turned on and off * Add syntax highlighting for code blocks (right-click to set it) * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log) * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
This commit is contained in:
parent
26737fbfb2
commit
e0169db52a
28 changed files with 4191 additions and 17 deletions
|
|
@ -1,8 +1,14 @@
|
|||
# 0.4.6
|
||||
# 0.5
|
||||
|
||||
* More Italian translations, thank you @mdaleo404
|
||||
* Set locked status on window title when locked
|
||||
* Don't exit on incorrect key, let it be tried again
|
||||
* Make reminders be its own dataset rather than tied to current string.
|
||||
* Add support for repeated reminders
|
||||
* Make reminders be a feature that can be turned on and off
|
||||
* Add syntax highlighting for code blocks (right-click to set it)
|
||||
* Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
|
||||
* Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
|
||||
|
||||
# 0.4.5
|
||||
|
||||
|
|
|
|||
365
bouquin/code_highlighter.py
Normal file
365
bouquin/code_highlighter.py
Normal 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
|
||||
102
bouquin/db.py
102
bouquin/db.py
|
|
@ -63,6 +63,7 @@ class DBConfig:
|
|||
move_todos: bool = False
|
||||
tags: bool = True
|
||||
time_log: bool = True
|
||||
reminders: bool = True
|
||||
locale: str = "en"
|
||||
font_size: int = 11
|
||||
|
||||
|
|
@ -195,6 +196,20 @@ class DBManager:
|
|||
ON time_log(project_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_time_log_activity
|
||||
ON time_log(activity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
time_str TEXT NOT NULL, -- HH:MM
|
||||
reminder_type TEXT NOT NULL, -- once|daily|weekdays|weekly
|
||||
weekday INTEGER, -- 0-6 for weekly (0=Mon)
|
||||
date_iso TEXT, -- for once type
|
||||
active INTEGER NOT NULL DEFAULT 1, -- 0=inactive, 1=active
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_reminders_active
|
||||
ON reminders(active);
|
||||
"""
|
||||
)
|
||||
self.conn.commit()
|
||||
|
|
@ -1015,3 +1030,90 @@ class DBManager:
|
|||
if self.conn is not None:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
|
||||
# ------------------------- Reminders logic here ------------------------#
|
||||
def save_reminder(self, reminder) -> int:
|
||||
"""Save or update a reminder. Returns the reminder ID."""
|
||||
cur = self.conn.cursor()
|
||||
if reminder.id:
|
||||
# Update existing
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET text = ?, time_str = ?, reminder_type = ?,
|
||||
weekday = ?, date_iso = ?, active = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
reminder.text,
|
||||
reminder.time_str,
|
||||
reminder.reminder_type.value,
|
||||
reminder.weekday,
|
||||
reminder.date_iso,
|
||||
1 if reminder.active else 0,
|
||||
reminder.id,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return reminder.id
|
||||
else:
|
||||
# Insert new
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO reminders (text, time_str, reminder_type, weekday, date_iso, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reminder.text,
|
||||
reminder.time_str,
|
||||
reminder.reminder_type.value,
|
||||
reminder.weekday,
|
||||
reminder.date_iso,
|
||||
1 if reminder.active else 0,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def get_all_reminders(self):
|
||||
"""Get all reminders."""
|
||||
from .reminders import Reminder, ReminderType
|
||||
|
||||
cur = self.conn.cursor()
|
||||
rows = cur.execute(
|
||||
"""
|
||||
SELECT id, text, time_str, reminder_type, weekday, date_iso, active
|
||||
FROM reminders
|
||||
ORDER BY time_str
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append(
|
||||
Reminder(
|
||||
id=r["id"],
|
||||
text=r["text"],
|
||||
time_str=r["time_str"],
|
||||
reminder_type=ReminderType(r["reminder_type"]),
|
||||
weekday=r["weekday"],
|
||||
date_iso=r["date_iso"],
|
||||
active=bool(r["active"]),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def update_reminder_active(self, reminder_id: int, active: bool) -> None:
|
||||
"""Update the active status of a reminder."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE reminders SET active = ? WHERE id = ?",
|
||||
(1 if active else 0, reminder_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def delete_reminder(self, reminder_id: int) -> None:
|
||||
"""Delete a reminder."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||
self.conn.commit()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@
|
|||
"backup_complete": "Backup complete",
|
||||
"backup_failed": "Backup failed",
|
||||
"quit": "Quit",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"save": "Save",
|
||||
"help": "Help",
|
||||
"saved": "Saved",
|
||||
"saved_to": "Saved to",
|
||||
|
|
@ -256,5 +259,44 @@
|
|||
"export_pdf_error_title": "PDF export failed",
|
||||
"export_pdf_error_message": "Could not write PDF file:\n{error}",
|
||||
"enable_tags_feature": "Enable Tags",
|
||||
"enable_time_log_feature": "Enable Time Logging"
|
||||
"enable_time_log_feature": "Enable Time Logging",
|
||||
"enable_reminders_feature": "Enable Reminders",
|
||||
"pomodoro_time_log_default_text": "Focus session",
|
||||
"toolbar_pomodoro_timer": "Time-logging timer",
|
||||
"set_code_language": "Set code language",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"edit_table": "Edit table",
|
||||
"toolbar_insert_table": "Insert table",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop_and_log": "Stop and log",
|
||||
"once": "once",
|
||||
"daily": "daily",
|
||||
"weekdays": "weekdays",
|
||||
"weekly": "weekly",
|
||||
"set_reminder": "Set reminder",
|
||||
"edit_reminder": "Edit reminder",
|
||||
"reminder": "Reminder",
|
||||
"time": "Time",
|
||||
"once_today": "Once (today)",
|
||||
"every_day": "Every day",
|
||||
"every_weekday": "Every weekday (Mon-Fri)",
|
||||
"every_week": "Every week",
|
||||
"repeat": "Repeat",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"day": "Day",
|
||||
"add_row": "Add row",
|
||||
"add_column": "Add column",
|
||||
"delete_row": "Delete row",
|
||||
"delete_column": "Delete column",
|
||||
"column": "Column"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,5 +121,16 @@
|
|||
"change_color": "Changer la couleur",
|
||||
"delete_tag": "Supprimer l'étiquette",
|
||||
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
|
||||
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà"
|
||||
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
|
||||
"cut" : "Couper",
|
||||
"copy" : "Copier",
|
||||
"paste" : "Coller",
|
||||
"monday" : "Lundi",
|
||||
"tuesday" : "Mardi",
|
||||
"wednesday" : "Mercredi",
|
||||
"thursday" : "Jeudi",
|
||||
"friday" : "Vendredi",
|
||||
"saturday" : "Samedi",
|
||||
"sunday" : "Dimanche",
|
||||
"day" : "Jour"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,5 +148,16 @@
|
|||
"bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
|
||||
"bug_report_placeholder": "Scrivi la tua segnalazione qui",
|
||||
"update": "Aggiornamento",
|
||||
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n"
|
||||
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"monday": "Lunedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì",
|
||||
"thursday": "Giovedì",
|
||||
"friday": "Venerdì",
|
||||
"saturday": "Sabato",
|
||||
"sunday": "Domenica",
|
||||
"day": "Giorno"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ from .history_dialog import HistoryDialog
|
|||
from .key_prompt import KeyPrompt
|
||||
from .lock_overlay import LockOverlay
|
||||
from .markdown_editor import MarkdownEditor
|
||||
from .pomodoro_timer import PomodoroManager
|
||||
from .reminders import UpcomingRemindersWidget
|
||||
from .save_dialog import SaveDialog
|
||||
from .search import Search
|
||||
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||
|
|
@ -106,12 +108,18 @@ class MainWindow(QMainWindow):
|
|||
self.search.openDateRequested.connect(self._load_selected_date)
|
||||
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
||||
|
||||
# Features
|
||||
self.time_log = TimeLogWidget(self.db)
|
||||
|
||||
self.tags = PageTagsWidget(self.db)
|
||||
self.tags.tagActivated.connect(self._on_tag_activated)
|
||||
self.tags.tagAdded.connect(self._on_tag_added)
|
||||
|
||||
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
||||
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
||||
|
||||
self.pomodoro_manager = PomodoroManager(self.db, self)
|
||||
|
||||
# Lock the calendar to the left panel at the top to stop it stretching
|
||||
# when the main window is resized.
|
||||
left_panel = QWidget()
|
||||
|
|
@ -119,6 +127,7 @@ class MainWindow(QMainWindow):
|
|||
left_layout.setContentsMargins(8, 8, 8, 8)
|
||||
left_layout.addWidget(self.calendar)
|
||||
left_layout.addWidget(self.search)
|
||||
left_layout.addWidget(self.upcoming_reminders)
|
||||
left_layout.addWidget(self.time_log)
|
||||
left_layout.addWidget(self.tags)
|
||||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||
|
|
@ -324,6 +333,10 @@ class MainWindow(QMainWindow):
|
|||
self.tags.hide()
|
||||
if not self.cfg.time_log:
|
||||
self.time_log.hide()
|
||||
self.toolBar.actTimer.setVisible(False)
|
||||
if not self.cfg.reminders:
|
||||
self.upcoming_reminders.hide()
|
||||
self.toolBar.actAlarm.setVisible(False)
|
||||
|
||||
# Restore window position from settings
|
||||
self._restore_window_position()
|
||||
|
|
@ -1087,6 +1100,8 @@ class MainWindow(QMainWindow):
|
|||
self._tb_numbers = lambda: self._call_editor("toggle_numbers")
|
||||
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
|
||||
self._tb_alarm = self._on_alarm_requested
|
||||
self._tb_timer = self._on_timer_requested
|
||||
self._tb_table = self._on_table_requested
|
||||
self._tb_font_larger = self._on_font_larger_requested
|
||||
self._tb_font_smaller = self._on_font_smaller_requested
|
||||
|
||||
|
|
@ -1099,6 +1114,8 @@ class MainWindow(QMainWindow):
|
|||
tb.numbersRequested.connect(self._tb_numbers)
|
||||
tb.checkboxesRequested.connect(self._tb_checkboxes)
|
||||
tb.alarmRequested.connect(self._tb_alarm)
|
||||
tb.timerRequested.connect(self._tb_timer)
|
||||
tb.tableRequested.connect(self._tb_table)
|
||||
tb.insertImageRequested.connect(self._on_insert_image)
|
||||
tb.historyRequested.connect(self._open_history)
|
||||
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
|
||||
|
|
@ -1228,6 +1245,23 @@ class MainWindow(QMainWindow):
|
|||
# Rebuild timers, but only if this page is for "today"
|
||||
self._rebuild_reminders_for_today()
|
||||
|
||||
def _on_timer_requested(self):
|
||||
"""Start a Pomodoro timer for the current line."""
|
||||
editor = getattr(self, "editor", None)
|
||||
if editor is None:
|
||||
return
|
||||
|
||||
# Get the current line text
|
||||
line_text = editor.get_current_line_text().strip()
|
||||
if not line_text:
|
||||
line_text = strings._("pomodoro_time_log_default_text")
|
||||
|
||||
# Get current date
|
||||
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
|
||||
# Start the timer
|
||||
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
|
||||
|
||||
def _show_flashing_reminder(self, text: str):
|
||||
"""
|
||||
Show a small flashing dialog and request attention from the OS.
|
||||
|
|
@ -1344,6 +1378,36 @@ class MainWindow(QMainWindow):
|
|||
timer.start(msecs)
|
||||
self._reminder_timers.append(timer)
|
||||
|
||||
# ----------- Table handler ------------#
|
||||
def _on_table_requested(self):
|
||||
"""Insert a basic markdown table template."""
|
||||
editor = getattr(self, "editor", None)
|
||||
if editor is None:
|
||||
return
|
||||
|
||||
# Basic 3x3 table template
|
||||
table_template = """| Column 1 | Column 2 | Column 3 |
|
||||
| --- | --- | --- |
|
||||
| Cell 1 | Cell 2 | Cell 3 |
|
||||
| Cell 4 | Cell 5 | Cell 6 |
|
||||
"""
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.insertText(table_template)
|
||||
|
||||
# Move cursor to first cell for easy editing
|
||||
# Find the start of "Column 1" text
|
||||
cursor.movePosition(
|
||||
QTextCursor.Left, QTextCursor.MoveAnchor, len(table_template)
|
||||
)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) # After "| "
|
||||
cursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.KeepAnchor, 8
|
||||
) # Select "Column 1"
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.setFocus()
|
||||
|
||||
# ----------- History handler ------------#
|
||||
def _open_history(self):
|
||||
if hasattr(self.editor, "current_date"):
|
||||
|
|
@ -1444,6 +1508,7 @@ class MainWindow(QMainWindow):
|
|||
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
||||
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
||||
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
||||
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
||||
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
||||
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
||||
|
||||
|
|
@ -1471,8 +1536,16 @@ class MainWindow(QMainWindow):
|
|||
self.tags.hide() if not self.cfg.tags else self.tags.show()
|
||||
if not self.cfg.time_log:
|
||||
self.time_log.hide()
|
||||
self.toolBar.actTimer.setVisible(False)
|
||||
else:
|
||||
self.time_log.show()
|
||||
self.toolBar.actTimer.setVisible(True)
|
||||
if not self.cfg.reminders:
|
||||
self.upcoming_reminders.hide()
|
||||
self.toolBar.actAlarm.setVisible(False)
|
||||
else:
|
||||
self.upcoming_reminders.show()
|
||||
self.toolBar.actAlarm.setVisible(True)
|
||||
|
||||
# ------------ Statistics handler --------------- #
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from PySide6.QtWidgets import QTextEdit
|
|||
|
||||
from .theme import ThemeManager
|
||||
from .markdown_highlighter import MarkdownHighlighter
|
||||
from . import strings
|
||||
|
||||
|
||||
class MarkdownEditor(QTextEdit):
|
||||
|
|
@ -63,7 +64,12 @@ class MarkdownEditor(QTextEdit):
|
|||
self._BULLET_STORAGE = "-"
|
||||
|
||||
# Install syntax highlighter
|
||||
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
|
||||
self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self)
|
||||
|
||||
# Initialize code block metadata
|
||||
from .code_highlighter import CodeBlockMetadata
|
||||
|
||||
self._code_metadata = CodeBlockMetadata()
|
||||
|
||||
# Track current list type for smart enter handling
|
||||
self._last_enter_was_empty = False
|
||||
|
|
@ -91,7 +97,9 @@ class MarkdownEditor(QTextEdit):
|
|||
# Recreate the highlighter for the new document
|
||||
# (the old one gets deleted with the old document)
|
||||
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
|
||||
self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager)
|
||||
self.highlighter = MarkdownHighlighter(
|
||||
self.document(), self.theme_manager, self
|
||||
)
|
||||
self._apply_line_spacing()
|
||||
self._apply_code_block_spacing()
|
||||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||||
|
|
@ -274,6 +282,12 @@ class MarkdownEditor(QTextEdit):
|
|||
text,
|
||||
)
|
||||
|
||||
# Append code block metadata if present
|
||||
if hasattr(self, "_code_metadata"):
|
||||
metadata_str = self._code_metadata.serialize()
|
||||
if metadata_str:
|
||||
text = text.rstrip() + "\n\n" + metadata_str
|
||||
|
||||
return text
|
||||
|
||||
def _extract_images_to_markdown(self) -> str:
|
||||
|
|
@ -312,6 +326,16 @@ class MarkdownEditor(QTextEdit):
|
|||
|
||||
def from_markdown(self, markdown_text: str):
|
||||
"""Load markdown text into the editor."""
|
||||
# Extract and load code block metadata if present
|
||||
from .code_highlighter import CodeBlockMetadata
|
||||
|
||||
if not hasattr(self, "_code_metadata"):
|
||||
self._code_metadata = CodeBlockMetadata()
|
||||
|
||||
self._code_metadata.deserialize(markdown_text)
|
||||
# Remove metadata comment from displayed text
|
||||
markdown_text = re.sub(r"\s*<!-- code-langs: [^>]+ -->\s*$", "", markdown_text)
|
||||
|
||||
# Convert markdown checkboxes to Unicode for display
|
||||
display_text = markdown_text.replace(
|
||||
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
|
||||
|
|
@ -432,10 +456,6 @@ class MarkdownEditor(QTextEdit):
|
|||
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||
return cursor.selectedText()
|
||||
|
||||
def get_current_line_text(self) -> str:
|
||||
"""Public wrapper used by MainWindow for reminders."""
|
||||
return self._get_current_line()
|
||||
|
||||
def _list_prefix_length_for_block(self, block) -> int:
|
||||
"""Return the length (in chars) of the visual list prefix for the given
|
||||
block (including leading indentation), or 0 if it's not a list item.
|
||||
|
|
@ -1218,3 +1238,114 @@ class MarkdownEditor(QTextEdit):
|
|||
cursor = self.textCursor()
|
||||
cursor.insertImage(img_format)
|
||||
cursor.insertText("\n") # Add newline after image
|
||||
|
||||
# ========== Context Menu Support ==========
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Override context menu to add custom actions."""
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu
|
||||
|
||||
menu = QMenu(self)
|
||||
cursor = self.cursorForPosition(event.pos())
|
||||
|
||||
# Check if we're in a table
|
||||
text = self.toPlainText()
|
||||
cursor_pos = cursor.position()
|
||||
|
||||
from .table_editor import find_table_at_cursor
|
||||
|
||||
table_info = find_table_at_cursor(text, cursor_pos)
|
||||
|
||||
if table_info:
|
||||
# Add table editing action
|
||||
edit_table_action = QAction(strings._("edit_table"), self)
|
||||
edit_table_action.triggered.connect(
|
||||
lambda: self._edit_table_at_cursor(cursor_pos)
|
||||
)
|
||||
menu.addAction(edit_table_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# Check if we're in a code block
|
||||
block = cursor.block()
|
||||
if self._is_inside_code_block(block):
|
||||
# Add language selection submenu
|
||||
lang_menu = menu.addMenu(strings._("set_code_language"))
|
||||
|
||||
languages = [
|
||||
"python",
|
||||
"bash",
|
||||
"php",
|
||||
"javascript",
|
||||
"html",
|
||||
"css",
|
||||
"sql",
|
||||
"java",
|
||||
"go",
|
||||
]
|
||||
for lang in languages:
|
||||
action = QAction(lang.capitalize(), self)
|
||||
action.triggered.connect(
|
||||
lambda checked, l=lang: self._set_code_block_language(block, l)
|
||||
)
|
||||
lang_menu.addAction(action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Add standard context menu actions
|
||||
if self.textCursor().hasSelection():
|
||||
menu.addAction(strings._("cut"), self.cut)
|
||||
menu.addAction(strings._("copy"), self.copy)
|
||||
|
||||
menu.addAction(strings._("paste"), self.paste)
|
||||
|
||||
menu.exec(event.globalPos())
|
||||
|
||||
def _edit_table_at_cursor(self, cursor_pos: int):
|
||||
"""Open table editor dialog for the table at cursor position."""
|
||||
from .table_editor import find_table_at_cursor, TableEditorDialog
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
text = self.toPlainText()
|
||||
table_info = find_table_at_cursor(text, cursor_pos)
|
||||
|
||||
if not table_info:
|
||||
return
|
||||
|
||||
start_pos, end_pos, table_text = table_info
|
||||
|
||||
# Open table editor
|
||||
dlg = TableEditorDialog(table_text, self)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
# Replace the table with edited version
|
||||
new_table = dlg.get_markdown_table()
|
||||
|
||||
cursor = QTextCursor(self.document())
|
||||
cursor.setPosition(start_pos)
|
||||
cursor.setPosition(end_pos, QTextCursor.KeepAnchor)
|
||||
cursor.insertText(new_table)
|
||||
|
||||
def _set_code_block_language(self, block, language: str):
|
||||
"""Set the language for a code block and store metadata."""
|
||||
if not hasattr(self, "_code_metadata"):
|
||||
from .code_highlighter import CodeBlockMetadata
|
||||
|
||||
self._code_metadata = CodeBlockMetadata()
|
||||
|
||||
# Find the opening fence block for this code block
|
||||
fence_block = block
|
||||
while fence_block.isValid() and not fence_block.text().strip().startswith(
|
||||
"```"
|
||||
):
|
||||
fence_block = fence_block.previous()
|
||||
|
||||
if fence_block.isValid():
|
||||
self._code_metadata.set_language(fence_block.blockNumber(), language)
|
||||
# Trigger rehighlight
|
||||
self.highlighter.rehighlight()
|
||||
|
||||
def get_current_line_text(self) -> str:
|
||||
"""Get the text of the current line."""
|
||||
cursor = self.textCursor()
|
||||
block = cursor.block()
|
||||
return block.text()
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ from .theme import ThemeManager, Theme
|
|||
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
||||
|
||||
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
|
||||
def __init__(
|
||||
self, document: QTextDocument, theme_manager: ThemeManager, editor=None
|
||||
):
|
||||
super().__init__(document)
|
||||
self.theme_manager = theme_manager
|
||||
self._editor = editor # Reference to the MarkdownEditor
|
||||
self._setup_formats()
|
||||
# Recompute formats whenever the app theme changes
|
||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||
|
|
@ -149,6 +152,36 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
if in_code_block:
|
||||
# inside code: apply block bg and language rules
|
||||
self.setFormat(0, len(text), self.code_block_format)
|
||||
|
||||
# Try to apply language-specific highlighting
|
||||
if self._editor and hasattr(self._editor, "_code_metadata"):
|
||||
from .code_highlighter import CodeHighlighter
|
||||
|
||||
# Find the opening fence block
|
||||
prev_block = self.currentBlock().previous()
|
||||
fence_block_num = None
|
||||
temp_inside = in_code_block
|
||||
|
||||
while prev_block.isValid():
|
||||
if prev_block.text().strip().startswith("```"):
|
||||
temp_inside = not temp_inside
|
||||
if not temp_inside:
|
||||
fence_block_num = prev_block.blockNumber()
|
||||
break
|
||||
prev_block = prev_block.previous()
|
||||
|
||||
if fence_block_num is not None:
|
||||
language = self._editor._code_metadata.get_language(fence_block_num)
|
||||
if language:
|
||||
patterns = CodeHighlighter.get_language_patterns(language)
|
||||
for pattern, syntax_type in patterns:
|
||||
for match in re.finditer(pattern, text):
|
||||
start, end = match.span()
|
||||
fmt = CodeHighlighter.get_format_for_type(
|
||||
syntax_type, self.code_block_format
|
||||
)
|
||||
self.setFormat(start, end - start, fmt)
|
||||
|
||||
self.setCurrentBlockState(1)
|
||||
return
|
||||
|
||||
|
|
|
|||
149
bouquin/pomodoro_timer.py
Normal file
149
bouquin/pomodoro_timer.py
Normal 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
637
bouquin/reminders.py
Normal 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()
|
||||
|
|
@ -43,6 +43,7 @@ def load_db_config() -> DBConfig:
|
|||
move_todos = s.value("ui/move_todos", False, type=bool)
|
||||
tags = s.value("ui/tags", True, type=bool)
|
||||
time_log = s.value("ui/time_log", True, type=bool)
|
||||
reminders = s.value("ui/reminders", True, type=bool)
|
||||
locale = s.value("ui/locale", "en", type=str)
|
||||
font_size = s.value("ui/font_size", 11, type=int)
|
||||
return DBConfig(
|
||||
|
|
@ -53,6 +54,7 @@ def load_db_config() -> DBConfig:
|
|||
move_todos=move_todos,
|
||||
tags=tags,
|
||||
time_log=time_log,
|
||||
reminders=reminders,
|
||||
locale=locale,
|
||||
font_size=font_size,
|
||||
)
|
||||
|
|
@ -67,5 +69,6 @@ def save_db_config(cfg: DBConfig) -> None:
|
|||
s.setValue("ui/move_todos", str(cfg.move_todos))
|
||||
s.setValue("ui/tags", str(cfg.tags))
|
||||
s.setValue("ui/time_log", str(cfg.time_log))
|
||||
s.setValue("ui/reminders", str(cfg.reminders))
|
||||
s.setValue("ui/locale", str(cfg.locale))
|
||||
s.setValue("ui/font_size", str(cfg.font_size))
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ class SettingsDialog(QDialog):
|
|||
self.time_log.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.time_log)
|
||||
|
||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||
self.reminders.setChecked(self.current_settings.reminders)
|
||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.reminders)
|
||||
|
||||
layout.addWidget(features_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
|
@ -302,6 +307,7 @@ class SettingsDialog(QDialog):
|
|||
move_todos=self.move_todos.isChecked(),
|
||||
tags=self.tags.isChecked(),
|
||||
time_log=self.time_log.isChecked(),
|
||||
reminders=self.reminders.isChecked(),
|
||||
locale=self.locale_combobox.currentText(),
|
||||
font_size=self.font_size.value(),
|
||||
)
|
||||
|
|
|
|||
255
bouquin/table_editor.py
Normal file
255
bouquin/table_editor.py
Normal 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
|
||||
|
|
@ -274,7 +274,7 @@ class TimeLogDialog(QDialog):
|
|||
# --- Close button
|
||||
close_row = QHBoxLayout()
|
||||
close_row.addStretch(1)
|
||||
close_btn = QPushButton("&" + strings._("close"))
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
close_row.addWidget(close_btn)
|
||||
root.addLayout(close_row)
|
||||
|
|
@ -572,7 +572,7 @@ class TimeCodeManagerDialog(QDialog):
|
|||
# Close
|
||||
close_row = QHBoxLayout()
|
||||
close_row.addStretch(1)
|
||||
close_btn = QPushButton("&" + strings._("close"))
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
close_row.addWidget(close_btn)
|
||||
root.addLayout(close_row)
|
||||
|
|
@ -916,7 +916,7 @@ class TimeReportDialog(QDialog):
|
|||
# Close
|
||||
close_row = QHBoxLayout()
|
||||
close_row.addStretch(1)
|
||||
close_btn = QPushButton("&" + strings._("close"))
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
close_row.addWidget(close_btn)
|
||||
root.addLayout(close_row)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class ToolBar(QToolBar):
|
|||
historyRequested = Signal()
|
||||
insertImageRequested = Signal()
|
||||
alarmRequested = Signal()
|
||||
timerRequested = Signal()
|
||||
tableRequested = Signal()
|
||||
fontSizeLargerRequested = Signal()
|
||||
fontSizeSmallerRequested = Signal()
|
||||
|
||||
|
|
@ -103,6 +105,16 @@ class ToolBar(QToolBar):
|
|||
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
|
||||
self.actAlarm.triggered.connect(self.alarmRequested)
|
||||
|
||||
# Focus timer
|
||||
self.actTimer = QAction("⌛", self)
|
||||
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
|
||||
self.actTimer.triggered.connect(self.timerRequested)
|
||||
|
||||
# Table
|
||||
self.actTable = QAction("⊞", self)
|
||||
self.actTable.setToolTip(strings._("toolbar_insert_table"))
|
||||
self.actTable.triggered.connect(self.tableRequested)
|
||||
|
||||
# Images
|
||||
self.actInsertImg = QAction("📸", self)
|
||||
self.actInsertImg.setToolTip(strings._("insert_images"))
|
||||
|
|
@ -151,6 +163,8 @@ class ToolBar(QToolBar):
|
|||
self.actNumbers,
|
||||
self.actCheckboxes,
|
||||
self.actAlarm,
|
||||
self.actTimer,
|
||||
self.actTable,
|
||||
self.actInsertImg,
|
||||
self.actHistory,
|
||||
]
|
||||
|
|
@ -177,6 +191,8 @@ class ToolBar(QToolBar):
|
|||
self._style_letter_button(self.actNumbers, "1.")
|
||||
self._style_letter_button(self.actCheckboxes, "☐")
|
||||
self._style_letter_button(self.actAlarm, "⏰")
|
||||
self._style_letter_button(self.actTimer, "⌛")
|
||||
self._style_letter_button(self.actTable, "⊞")
|
||||
|
||||
# History
|
||||
self._style_letter_button(self.actHistory, "⎌")
|
||||
|
|
|
|||
|
|
@ -408,5 +408,5 @@ class VersionChecker:
|
|||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("downloaded_and_verified_new_appimage") + appimage_path,
|
||||
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "bouquin"
|
||||
version = "0.4.5"
|
||||
version = "0.5"
|
||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,16 @@ def tmp_db_cfg(tmp_path):
|
|||
default_db = tmp_path / "notebook.db"
|
||||
key = "test-secret-key"
|
||||
return DBConfig(
|
||||
path=default_db, key=key, idle_minutes=0, theme="light", move_todos=True
|
||||
path=default_db,
|
||||
key=key,
|
||||
idle_minutes=0,
|
||||
theme="light",
|
||||
move_todos=True,
|
||||
tags=True,
|
||||
time_log=True,
|
||||
reminders=True,
|
||||
locale="en",
|
||||
font_size=11,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
398
tests/test_code_highlighter.py
Normal file
398
tests/test_code_highlighter.py
Normal 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)
|
||||
|
|
@ -25,6 +25,11 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
|
|
|
|||
354
tests/test_pomodoro_timer.py
Normal file
354
tests/test_pomodoro_timer.py
Normal 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
657
tests/test_reminders.py
Normal 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()
|
||||
|
|
@ -15,7 +15,11 @@ def _clear_db_settings():
|
|||
"ui/idle_minutes",
|
||||
"ui/theme",
|
||||
"ui/move_todos",
|
||||
"ui/tags",
|
||||
"ui/time_log",
|
||||
"ui/reminders",
|
||||
"ui/locale",
|
||||
"ui/font_size",
|
||||
]:
|
||||
s.remove(k)
|
||||
|
||||
|
|
@ -29,7 +33,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
|||
idle_minutes=7,
|
||||
theme="dark",
|
||||
move_todos=True,
|
||||
tags=True,
|
||||
time_log=True,
|
||||
reminders=True,
|
||||
locale="en",
|
||||
font_size=11,
|
||||
)
|
||||
save_db_config(cfg)
|
||||
|
||||
|
|
@ -39,7 +47,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
|||
assert loaded.idle_minutes == cfg.idle_minutes
|
||||
assert loaded.theme == cfg.theme
|
||||
assert loaded.move_todos == cfg.move_todos
|
||||
assert loaded.tags == cfg.tags
|
||||
assert loaded.time_log == cfg.time_log
|
||||
assert loaded.reminders == cfg.reminders
|
||||
assert loaded.locale == cfg.locale
|
||||
assert loaded.font_size == cfg.font_size
|
||||
|
||||
|
||||
def test_load_db_config_migrates_legacy_db_path(app, tmp_path):
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
|||
dlg.move_todos.setChecked(True)
|
||||
dlg.tags.setChecked(False)
|
||||
dlg.time_log.setChecked(False)
|
||||
dlg.reminders.setChecked(False)
|
||||
|
||||
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
|
||||
def _auto_accept_msgbox():
|
||||
|
|
@ -39,6 +40,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
|||
assert cfg.move_todos is True
|
||||
assert cfg.tags is False
|
||||
assert cfg.time_log is False
|
||||
assert cfg.reminders is False
|
||||
assert cfg.theme in ("light", "dark", "system")
|
||||
|
||||
|
||||
|
|
|
|||
384
tests/test_table_editor.py
Normal file
384
tests/test_table_editor.py
Normal 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
512
tests/test_version_check.py
Normal 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()
|
||||
|
|
@ -18,6 +18,7 @@ MarkdownEditor.apply_italic
|
|||
MarkdownEditor.apply_strikethrough
|
||||
MarkdownEditor.apply_code
|
||||
MarkdownEditor.apply_heading
|
||||
MarkdownEditor.contextMenuEvent
|
||||
MarkdownEditor.toggle_bullets
|
||||
MarkdownEditor.toggle_numbers
|
||||
MarkdownEditor.toggle_checkboxes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue