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