Code cleanup, more tests
This commit is contained in:
parent
1c0052a0cf
commit
bfd0314109
16 changed files with 1212 additions and 478 deletions
|
|
@ -98,31 +98,6 @@ class DBManager:
|
|||
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
|
||||
"""
|
||||
)
|
||||
|
||||
# If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
|
||||
pre_0_1_5 = cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
|
||||
).fetchone()
|
||||
pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
|
||||
|
||||
if pre_0_1_5 and pages_empty:
|
||||
# Seed pages and versions (all as version 1)
|
||||
cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
|
||||
cur.execute(
|
||||
"INSERT INTO versions(date, version_no, content) "
|
||||
"SELECT date, 1, content FROM entries;"
|
||||
)
|
||||
# Point head to v1 for each page
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE pages
|
||||
SET current_version_id = (
|
||||
SELECT v.id FROM versions v
|
||||
WHERE v.date = pages.date AND v.version_no = 1
|
||||
);
|
||||
"""
|
||||
)
|
||||
cur.execute("DROP TABLE IF EXISTS entries;")
|
||||
self.conn.commit()
|
||||
|
||||
def rekey(self, new_key: str) -> None:
|
||||
|
|
@ -130,8 +105,6 @@ class DBManager:
|
|||
Change the SQLCipher passphrase in-place, then reopen the connection
|
||||
with the new key to verify.
|
||||
"""
|
||||
if self.conn is None:
|
||||
raise RuntimeError("Database is not connected")
|
||||
cur = self.conn.cursor()
|
||||
# Change the encryption key of the currently open database
|
||||
cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
|
||||
|
|
@ -191,7 +164,8 @@ class DBManager:
|
|||
"""
|
||||
SELECT p.date
|
||||
FROM pages p
|
||||
JOIN versions v ON v.id = p.current_version_id
|
||||
JOIN versions v
|
||||
ON v.id = p.current_version_id
|
||||
WHERE TRIM(v.content) <> ''
|
||||
ORDER BY p.date;
|
||||
"""
|
||||
|
|
@ -210,8 +184,6 @@ class DBManager:
|
|||
Append a new version for this date. Returns (version_id, version_no).
|
||||
If set_current=True, flips the page head to this new version.
|
||||
"""
|
||||
if self.conn is None:
|
||||
raise RuntimeError("Database is not connected")
|
||||
with self.conn: # transaction
|
||||
cur = self.conn.cursor()
|
||||
# Ensure page row exists
|
||||
|
|
@ -326,44 +298,13 @@ class DBManager:
|
|||
entries: Sequence[Entry],
|
||||
file_path: str,
|
||||
separator: str = "\n\n— — — — —\n\n",
|
||||
strip_html: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Strip the HTML from the latest version of the pages
|
||||
and save to a text file.
|
||||
Strip the the latest version of the pages to a text file.
|
||||
"""
|
||||
import re, html as _html
|
||||
|
||||
# Precompiled patterns
|
||||
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
||||
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
||||
BR_RE = re.compile(r"(?i)<br\\s*/?>")
|
||||
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
|
||||
MULTINEWLINE_RE = re.compile(r"\\n{3,}")
|
||||
|
||||
def _strip(s: str) -> str:
|
||||
# 1) Remove <style> and <script> blocks *including their contents*
|
||||
s = STYLE_SCRIPT_RE.sub("", s)
|
||||
# 2) Remove HTML comments
|
||||
s = COMMENT_RE.sub("", s)
|
||||
# 3) Turn some block-ish boundaries into newlines before removing tags
|
||||
s = BR_RE.sub("\n", s)
|
||||
s = BLOCK_END_RE.sub("\n", s)
|
||||
# 4) Drop remaining tags
|
||||
s = TAG_RE.sub("", s)
|
||||
# 5) Unescape entities ( etc.)
|
||||
s = _html.unescape(s)
|
||||
# 6) Tidy whitespace
|
||||
s = WS_ENDS_RE.sub("\n", s)
|
||||
s = MULTINEWLINE_RE.sub("\n\n", s)
|
||||
return s.strip()
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
for i, (d, c) in enumerate(entries):
|
||||
body = _strip(c) if strip_html else c
|
||||
f.write(f"{d}\n{body}\n")
|
||||
f.write(f"{d}\n{c}\n")
|
||||
if i < len(entries) - 1:
|
||||
f.write(separator)
|
||||
|
||||
|
|
@ -396,8 +337,8 @@ class DBManager:
|
|||
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
||||
) -> None:
|
||||
"""
|
||||
Export to HTML, similar to export_html, but then convert to Markdown
|
||||
using markdownify, and finally save to file.
|
||||
Export the data to a markdown file. Since the data is already Markdown,
|
||||
nothing more to do.
|
||||
"""
|
||||
parts = []
|
||||
for d, c in entries:
|
||||
|
|
|
|||
|
|
@ -288,6 +288,19 @@ class MainWindow(QMainWindow):
|
|||
# apply once on startup so links / calendar colors are set immediately
|
||||
self._retheme_overrides()
|
||||
|
||||
@property
|
||||
def editor(self) -> MarkdownEditor | None:
|
||||
"""Get the currently active editor."""
|
||||
return self.tab_widget.currentWidget()
|
||||
|
||||
def _call_editor(self, method_name, *args):
|
||||
"""
|
||||
Call the relevant method of the MarkdownEditor class on bind
|
||||
"""
|
||||
getattr(self.editor, method_name)(*args)
|
||||
|
||||
# ----------- Database connection/key management methods ------------ #
|
||||
|
||||
def _try_connect(self) -> bool:
|
||||
"""
|
||||
Try to connect to the database.
|
||||
|
|
@ -488,17 +501,6 @@ class MainWindow(QMainWindow):
|
|||
# Remember this as the "previous" editor for next switch
|
||||
self._prev_editor = editor
|
||||
|
||||
def _call_editor(self, method_name, *args):
|
||||
"""
|
||||
Call the relevant method of the MarkdownEditor class on bind
|
||||
"""
|
||||
getattr(self.editor, method_name)(*args)
|
||||
|
||||
@property
|
||||
def editor(self) -> MarkdownEditor | None:
|
||||
"""Get the currently active editor."""
|
||||
return self.tab_widget.currentWidget()
|
||||
|
||||
def _date_from_calendar_pos(self, pos) -> QDate | None:
|
||||
"""Translate a QCalendarWidget local pos to the QDate under the cursor."""
|
||||
view: QTableView = self.calendar.findChild(
|
||||
|
|
@ -599,6 +601,215 @@ class MainWindow(QMainWindow):
|
|||
if action == open_in_new_tab_action and clicked_date and clicked_date.isValid():
|
||||
self._open_date_in_tab(clicked_date)
|
||||
|
||||
def _load_selected_date(self, date_iso=False, extra_data=False):
|
||||
"""Load a date into the current editor"""
|
||||
if not date_iso:
|
||||
date_iso = self._current_date_iso()
|
||||
|
||||
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||
# Keep calendar in sync
|
||||
with QSignalBlocker(self.calendar):
|
||||
self.calendar.setSelectedDate(qd)
|
||||
|
||||
self._load_date_into_editor(qd, extra_data)
|
||||
self.editor.current_date = qd
|
||||
|
||||
# Update tab title
|
||||
current_index = self.tab_widget.currentIndex()
|
||||
if current_index >= 0:
|
||||
self.tab_widget.setTabText(current_index, date_iso)
|
||||
|
||||
# Keep tabs sorted by date
|
||||
self._reorder_tabs_by_date()
|
||||
|
||||
def _load_date_into_editor(self, date: QDate, extra_data=False):
|
||||
"""Load a specific date's content into a given editor."""
|
||||
date_iso = date.toString("yyyy-MM-dd")
|
||||
text = self.db.get_entry(date_iso)
|
||||
if extra_data:
|
||||
# Append extra data as markdown
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
text += extra_data
|
||||
# Force a save now so we don't lose it.
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
self._dirty = True
|
||||
self._save_date(date_iso, True)
|
||||
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
self._dirty = False
|
||||
|
||||
def _set_editor_markdown_preserve_view(self, markdown: str):
|
||||
|
||||
# Save caret/selection and scroll
|
||||
cur = self.editor.textCursor()
|
||||
old_pos, old_anchor = cur.position(), cur.anchor()
|
||||
v = self.editor.verticalScrollBar().value()
|
||||
h = self.editor.horizontalScrollBar().value()
|
||||
|
||||
# Only touch the doc if it actually changed
|
||||
self.editor.blockSignals(True)
|
||||
if self.editor.to_markdown() != markdown:
|
||||
self.editor.from_markdown(markdown)
|
||||
self.editor.blockSignals(False)
|
||||
|
||||
# Restore scroll first
|
||||
self.editor.verticalScrollBar().setValue(v)
|
||||
self.editor.horizontalScrollBar().setValue(h)
|
||||
|
||||
# Restore caret/selection (bounded to new doc length)
|
||||
doc_length = self.editor.document().characterCount() - 1
|
||||
old_pos = min(old_pos, doc_length)
|
||||
old_anchor = min(old_anchor, doc_length)
|
||||
|
||||
cur = self.editor.textCursor()
|
||||
cur.setPosition(old_anchor)
|
||||
mode = (
|
||||
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
|
||||
)
|
||||
cur.setPosition(old_pos, mode)
|
||||
self.editor.setTextCursor(cur)
|
||||
|
||||
# Refresh highlights if the theme changed
|
||||
if hasattr(self, "findBar"):
|
||||
self.findBar.refresh()
|
||||
|
||||
def _save_editor_content(self, editor: MarkdownEditor):
|
||||
"""Save a specific editor's content to its associated date."""
|
||||
if not hasattr(editor, "current_date"):
|
||||
return
|
||||
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
||||
md = editor.to_markdown()
|
||||
self.db.save_new_version(date_iso, md, note="autosave")
|
||||
|
||||
def _on_text_changed(self):
|
||||
self._dirty = True
|
||||
self._save_timer.start(5000) # autosave after idle
|
||||
|
||||
def _adjust_day(self, delta: int):
|
||||
"""Move selection by delta days (negative for previous)."""
|
||||
d = self.calendar.selectedDate().addDays(delta)
|
||||
self.calendar.setSelectedDate(d)
|
||||
|
||||
def _adjust_today(self):
|
||||
"""Jump to today."""
|
||||
today = QDate.currentDate()
|
||||
self._create_new_tab(today)
|
||||
|
||||
def _load_yesterday_todos(self):
|
||||
if not self.cfg.move_todos:
|
||||
return
|
||||
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||
text = self.db.get_entry(yesterday_str)
|
||||
unchecked_items = []
|
||||
|
||||
# Split into lines and find unchecked checkbox items
|
||||
lines = text.split("\n")
|
||||
remaining_lines = []
|
||||
|
||||
for line in lines:
|
||||
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
|
||||
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
||||
r"^\s*-\s*\[☐\]\s+", line
|
||||
):
|
||||
# Extract the text after the checkbox
|
||||
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
||||
unchecked_items.append(f"- [ ] {item_text}")
|
||||
else:
|
||||
# Keep all other lines
|
||||
remaining_lines.append(line)
|
||||
|
||||
# Save modified content back if we moved items
|
||||
if unchecked_items:
|
||||
modified_text = "\n".join(remaining_lines)
|
||||
self.db.save_new_version(
|
||||
yesterday_str,
|
||||
modified_text,
|
||||
"Unchecked checkbox items moved to next day",
|
||||
)
|
||||
|
||||
# Join unchecked items into markdown format
|
||||
unchecked_str = "\n".join(unchecked_items) + "\n"
|
||||
|
||||
# Load the unchecked items into the current editor
|
||||
self._load_selected_date(False, unchecked_str)
|
||||
else:
|
||||
return False
|
||||
|
||||
def _on_date_changed(self):
|
||||
"""
|
||||
When the calendar selection changes, save the previous day's note if dirty,
|
||||
so we don't lose that text, then load the newly selected day into current tab.
|
||||
"""
|
||||
# Skip if we're showing a context menu (right-click shouldn't load dates)
|
||||
if getattr(self, "_showing_context_menu", False):
|
||||
return
|
||||
|
||||
# Stop pending autosave and persist current buffer if needed
|
||||
try:
|
||||
self._save_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save the current editor's content if dirty
|
||||
if hasattr(self.editor, "current_date") and self._dirty:
|
||||
prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
self._save_date(prev_date_iso, explicit=False)
|
||||
|
||||
# Now load the newly selected date into the current tab
|
||||
new_date = self.calendar.selectedDate()
|
||||
self._load_date_into_editor(new_date)
|
||||
self.editor.current_date = new_date
|
||||
|
||||
# Update tab title
|
||||
current_index = self.tab_widget.currentIndex()
|
||||
if current_index >= 0:
|
||||
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
|
||||
|
||||
# Keep tabs sorted by date
|
||||
self._reorder_tabs_by_date()
|
||||
|
||||
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
||||
"""
|
||||
Save editor contents into the given date. Shows status on success.
|
||||
explicit=True means user invoked Save: show feedback even if nothing changed.
|
||||
"""
|
||||
if not self._dirty and not explicit:
|
||||
return
|
||||
text = self.editor.to_markdown()
|
||||
self.db.save_new_version(date_iso, text, note)
|
||||
self._dirty = False
|
||||
self._refresh_calendar_marks()
|
||||
# Feedback in the status bar
|
||||
from datetime import datetime as _dt
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
|
||||
)
|
||||
|
||||
def _save_current(self, explicit: bool = False):
|
||||
"""Save the current editor's content."""
|
||||
try:
|
||||
self._save_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if explicit:
|
||||
# Prompt for a note
|
||||
dlg = SaveDialog(self)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return
|
||||
note = dlg.note_text()
|
||||
else:
|
||||
note = "autosave"
|
||||
# Save the current editor's date
|
||||
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
self._save_date(date_iso, explicit, note)
|
||||
try:
|
||||
self._save_timer.start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ----------------- Some theme helpers -------------------#
|
||||
def _retheme_overrides(self):
|
||||
if hasattr(self, "_lock_overlay"):
|
||||
|
|
@ -620,15 +831,7 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
css = "" # Default to no custom styling for links (system or light theme)
|
||||
|
||||
try:
|
||||
self.editor.document().setDefaultStyleSheet(css)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.search.document().setDefaultStyleSheet(css)
|
||||
except Exception:
|
||||
pass
|
||||
self.editor.document().setDefaultStyleSheet(css)
|
||||
|
||||
def _apply_calendar_theme(self, theme: Theme):
|
||||
"""Use orange accents on the calendar in dark mode only."""
|
||||
|
|
@ -681,6 +884,8 @@ class MainWindow(QMainWindow):
|
|||
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
|
||||
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
|
||||
|
||||
# --------------- Search sidebar/results helpers ---------------- #
|
||||
|
||||
def _on_search_dates_changed(self, date_strs: list[str]):
|
||||
dates = set()
|
||||
for ds in date_strs or []:
|
||||
|
|
@ -721,18 +926,15 @@ class MainWindow(QMainWindow):
|
|||
fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
|
||||
self.calendar.setDateTextFormat(d, fmt)
|
||||
self._marked_dates = set()
|
||||
try:
|
||||
for date_iso in self.db.dates_with_content():
|
||||
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||
if qd.isValid():
|
||||
fmt = self.calendar.dateTextFormat(qd)
|
||||
fmt.setFontWeight(QFont.Weight.Bold) # add bold only
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
self._marked_dates.add(qd)
|
||||
except Exception:
|
||||
pass
|
||||
for date_iso in self.db.dates_with_content():
|
||||
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||
if qd.isValid():
|
||||
fmt = self.calendar.dateTextFormat(qd)
|
||||
fmt.setFontWeight(QFont.Weight.Bold) # add bold only
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
self._marked_dates.add(qd)
|
||||
|
||||
# --- UI handlers ---------------------------------------------------------
|
||||
# -------------------- UI handlers ------------------- #
|
||||
|
||||
def _bind_toolbar(self):
|
||||
if getattr(self, "_toolbar_bound", False):
|
||||
|
|
@ -805,150 +1007,6 @@ class MainWindow(QMainWindow):
|
|||
self.toolBar.actBullets.setChecked(bool(bullets_on))
|
||||
self.toolBar.actNumbers.setChecked(bool(numbers_on))
|
||||
|
||||
def _load_selected_date(self, date_iso=False, extra_data=False):
|
||||
"""Load a date into the current editor"""
|
||||
if not date_iso:
|
||||
date_iso = self._current_date_iso()
|
||||
|
||||
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||
# Keep calendar in sync
|
||||
with QSignalBlocker(self.calendar):
|
||||
self.calendar.setSelectedDate(qd)
|
||||
|
||||
self._load_date_into_editor(qd, extra_data)
|
||||
self.editor.current_date = qd
|
||||
|
||||
# Update tab title
|
||||
current_index = self.tab_widget.currentIndex()
|
||||
if current_index >= 0:
|
||||
self.tab_widget.setTabText(current_index, date_iso)
|
||||
|
||||
# Keep tabs sorted by date
|
||||
self._reorder_tabs_by_date()
|
||||
|
||||
def _load_date_into_editor(self, date: QDate, extra_data=False):
|
||||
"""Load a specific date's content into a given editor."""
|
||||
date_iso = date.toString("yyyy-MM-dd")
|
||||
try:
|
||||
text = self.db.get_entry(date_iso)
|
||||
if extra_data:
|
||||
# Append extra data as markdown
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
text += extra_data
|
||||
# Force a save now so we don't lose it.
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
self._dirty = True
|
||||
self._save_date(date_iso, True)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Read Error", str(e))
|
||||
return
|
||||
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
self._dirty = False
|
||||
|
||||
def _save_editor_content(self, editor: MarkdownEditor):
|
||||
"""Save a specific editor's content to its associated date."""
|
||||
if not hasattr(editor, "current_date"):
|
||||
return
|
||||
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
||||
try:
|
||||
md = editor.to_markdown()
|
||||
self.db.save_new_version(date_iso, md, note="autosave")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Save Error", str(e))
|
||||
|
||||
def _on_text_changed(self):
|
||||
self._dirty = True
|
||||
self._save_timer.start(5000) # autosave after idle
|
||||
|
||||
def _adjust_day(self, delta: int):
|
||||
"""Move selection by delta days (negative for previous)."""
|
||||
d = self.calendar.selectedDate().addDays(delta)
|
||||
self.calendar.setSelectedDate(d)
|
||||
|
||||
def _adjust_today(self):
|
||||
"""Jump to today."""
|
||||
today = QDate.currentDate()
|
||||
self._create_new_tab(today)
|
||||
|
||||
def _load_yesterday_todos(self):
|
||||
try:
|
||||
if not self.cfg.move_todos:
|
||||
return
|
||||
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||
text = self.db.get_entry(yesterday_str)
|
||||
unchecked_items = []
|
||||
|
||||
# Split into lines and find unchecked checkbox items
|
||||
lines = text.split("\n")
|
||||
remaining_lines = []
|
||||
|
||||
for line in lines:
|
||||
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
|
||||
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
||||
r"^\s*-\s*\[☐\]\s+", line
|
||||
):
|
||||
# Extract the text after the checkbox
|
||||
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
||||
unchecked_items.append(f"- [ ] {item_text}")
|
||||
else:
|
||||
# Keep all other lines
|
||||
remaining_lines.append(line)
|
||||
|
||||
# Save modified content back if we moved items
|
||||
if unchecked_items:
|
||||
modified_text = "\n".join(remaining_lines)
|
||||
self.db.save_new_version(
|
||||
yesterday_str,
|
||||
modified_text,
|
||||
"Unchecked checkbox items moved to next day",
|
||||
)
|
||||
|
||||
# Join unchecked items into markdown format
|
||||
unchecked_str = "\n".join(unchecked_items) + "\n"
|
||||
|
||||
# Load the unchecked items into the current editor
|
||||
self._load_selected_date(False, unchecked_str)
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
raise SystemError(e)
|
||||
|
||||
def _on_date_changed(self):
|
||||
"""
|
||||
When the calendar selection changes, save the previous day's note if dirty,
|
||||
so we don't lose that text, then load the newly selected day into current tab.
|
||||
"""
|
||||
# Skip if we're showing a context menu (right-click shouldn't load dates)
|
||||
if getattr(self, "_showing_context_menu", False):
|
||||
return
|
||||
|
||||
# Stop pending autosave and persist current buffer if needed
|
||||
try:
|
||||
self._save_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save the current editor's content if dirty
|
||||
if hasattr(self.editor, "current_date") and self._dirty:
|
||||
prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
self._save_date(prev_date_iso, explicit=False)
|
||||
|
||||
# Now load the newly selected date into the current tab
|
||||
new_date = self.calendar.selectedDate()
|
||||
self._load_date_into_editor(new_date)
|
||||
self.editor.current_date = new_date
|
||||
|
||||
# Update tab title
|
||||
current_index = self.tab_widget.currentIndex()
|
||||
if current_index >= 0:
|
||||
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
|
||||
|
||||
# Keep tabs sorted by date
|
||||
self._reorder_tabs_by_date()
|
||||
|
||||
# ----------- History handler ------------#
|
||||
def _open_history(self):
|
||||
if hasattr(self.editor, "current_date"):
|
||||
|
|
@ -977,52 +1035,6 @@ class MainWindow(QMainWindow):
|
|||
for path_str in paths:
|
||||
self.editor.insert_image_from_path(Path(path_str))
|
||||
|
||||
# --------------- Database saving of content ---------------- #
|
||||
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
||||
"""
|
||||
Save editor contents into the given date. Shows status on success.
|
||||
explicit=True means user invoked Save: show feedback even if nothing changed.
|
||||
"""
|
||||
if not self._dirty and not explicit:
|
||||
return
|
||||
text = self.editor.to_markdown()
|
||||
try:
|
||||
self.db.save_new_version(date_iso, text, note)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Save Error", str(e))
|
||||
return
|
||||
self._dirty = False
|
||||
self._refresh_calendar_marks()
|
||||
# Feedback in the status bar
|
||||
from datetime import datetime as _dt
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
|
||||
)
|
||||
|
||||
def _save_current(self, explicit: bool = False):
|
||||
"""Save the current editor's content."""
|
||||
try:
|
||||
self._save_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if explicit:
|
||||
# Prompt for a note
|
||||
dlg = SaveDialog(self)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return
|
||||
note = dlg.note_text()
|
||||
else:
|
||||
note = "autosave"
|
||||
# Save the current editor's date
|
||||
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
self._save_date(date_iso, explicit, note)
|
||||
try:
|
||||
self._save_timer.start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ----------- Settings handler ------------#
|
||||
def _open_settings(self):
|
||||
dlg = SettingsDialog(self.cfg, self.db, self)
|
||||
|
|
@ -1218,12 +1230,9 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
self._idle_timer.stop()
|
||||
# If currently locked, unlock when user disables the timer:
|
||||
if getattr(self, "_locked", False):
|
||||
try:
|
||||
self._locked = False
|
||||
if hasattr(self, "_lock_overlay"):
|
||||
self._lock_overlay.hide()
|
||||
except Exception:
|
||||
pass
|
||||
self._locked = False
|
||||
if hasattr(self, "_lock_overlay"):
|
||||
self._lock_overlay.hide()
|
||||
else:
|
||||
self._idle_timer.setInterval(minutes * 60 * 1000)
|
||||
if not getattr(self, "_locked", False):
|
||||
|
|
@ -1232,12 +1241,9 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
def eventFilter(self, obj, event):
|
||||
# Catch right-clicks on calendar BEFORE selectionChanged can fire
|
||||
if obj == self.calendar and event.type() == QEvent.MouseButtonPress:
|
||||
try:
|
||||
# QMouseEvent in PySide6
|
||||
if event.button() == Qt.RightButton:
|
||||
self._showing_context_menu = True
|
||||
except Exception:
|
||||
pass
|
||||
# QMouseEvent in PySide6
|
||||
if event.button() == Qt.RightButton:
|
||||
self._showing_context_menu = True
|
||||
|
||||
if event.type() == QEvent.KeyPress and not self._locked:
|
||||
self._idle_timer.start()
|
||||
|
|
@ -1290,20 +1296,17 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
|
||||
# ----------------- Close handlers ----------------- #
|
||||
def closeEvent(self, event):
|
||||
try:
|
||||
# Save window position
|
||||
self.settings.setValue("main/geometry", self.saveGeometry())
|
||||
self.settings.setValue("main/windowState", self.saveState())
|
||||
self.settings.setValue("main/maximized", self.isMaximized())
|
||||
# Save window position
|
||||
self.settings.setValue("main/geometry", self.saveGeometry())
|
||||
self.settings.setValue("main/windowState", self.saveState())
|
||||
self.settings.setValue("main/maximized", self.isMaximized())
|
||||
|
||||
# Ensure we save all tabs before closing
|
||||
for i in range(self.tab_widget.count()):
|
||||
editor = self.tab_widget.widget(i)
|
||||
if editor:
|
||||
self._save_editor_content(editor)
|
||||
self.db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure we save all tabs before closing
|
||||
for i in range(self.tab_widget.count()):
|
||||
editor = self.tab_widget.widget(i)
|
||||
if editor:
|
||||
self._save_editor_content(editor)
|
||||
self.db.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
# ----------------- Below logic helps focus the editor ----------------- #
|
||||
|
|
@ -1339,38 +1342,3 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
super().changeEvent(ev)
|
||||
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
||||
QTimer.singleShot(0, self._focus_editor_now)
|
||||
|
||||
def _set_editor_markdown_preserve_view(self, markdown: str):
|
||||
|
||||
# Save caret/selection and scroll
|
||||
cur = self.editor.textCursor()
|
||||
old_pos, old_anchor = cur.position(), cur.anchor()
|
||||
v = self.editor.verticalScrollBar().value()
|
||||
h = self.editor.horizontalScrollBar().value()
|
||||
|
||||
# Only touch the doc if it actually changed
|
||||
self.editor.blockSignals(True)
|
||||
if self.editor.to_markdown() != markdown:
|
||||
self.editor.from_markdown(markdown)
|
||||
self.editor.blockSignals(False)
|
||||
|
||||
# Restore scroll first
|
||||
self.editor.verticalScrollBar().setValue(v)
|
||||
self.editor.horizontalScrollBar().setValue(h)
|
||||
|
||||
# Restore caret/selection (bounded to new doc length)
|
||||
doc_length = self.editor.document().characterCount() - 1
|
||||
old_pos = min(old_pos, doc_length)
|
||||
old_anchor = min(old_anchor, doc_length)
|
||||
|
||||
cur = self.editor.textCursor()
|
||||
cur.setPosition(old_anchor)
|
||||
mode = (
|
||||
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
|
||||
)
|
||||
cur.setPosition(old_pos, mode)
|
||||
self.editor.setTextCursor(cur)
|
||||
|
||||
# Refresh highlights if the theme changed
|
||||
if hasattr(self, "findBar"):
|
||||
self.findBar.refresh()
|
||||
|
|
|
|||
|
|
@ -32,11 +32,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.theme_manager = theme_manager
|
||||
self._setup_formats()
|
||||
# Recompute formats whenever the app theme changes
|
||||
try:
|
||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||
self.textChanged.connect(self._refresh_codeblock_margins)
|
||||
except Exception:
|
||||
pass
|
||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||
|
||||
def _on_theme_changed(self, *_):
|
||||
self._setup_formats()
|
||||
|
|
@ -57,7 +53,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.strike_format = QTextCharFormat()
|
||||
self.strike_format.setFontStrikeOut(True)
|
||||
|
||||
# Code: `code`
|
||||
# Inline code: `code`
|
||||
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self.code_format = QTextCharFormat()
|
||||
self.code_format.setFont(mono)
|
||||
|
|
@ -100,36 +96,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
# Also make them very faint in case they still show
|
||||
self.syntax_format.setForeground(QColor(250, 250, 250))
|
||||
|
||||
def _refresh_codeblock_margins(self):
|
||||
"""Give code blocks a small left/right margin to separate them visually."""
|
||||
doc = self.document()
|
||||
block = doc.begin()
|
||||
in_code = False
|
||||
while block.isValid():
|
||||
txt = block.text().strip()
|
||||
cursor = QTextCursor(block)
|
||||
fmt = block.blockFormat()
|
||||
|
||||
if txt.startswith("```"):
|
||||
# fence lines: small vertical spacing, same left indent
|
||||
need = (12, 6, 6) # left, top, bottom (px-like)
|
||||
if (fmt.leftMargin(), fmt.topMargin(), fmt.bottomMargin()) != need:
|
||||
fmt.setLeftMargin(12)
|
||||
fmt.setRightMargin(6)
|
||||
fmt.setTopMargin(6)
|
||||
fmt.setBottomMargin(6)
|
||||
cursor.setBlockFormat(fmt)
|
||||
in_code = not in_code
|
||||
|
||||
elif in_code:
|
||||
# inside the code block
|
||||
if fmt.leftMargin() != 12 or fmt.rightMargin() != 6:
|
||||
fmt.setLeftMargin(12)
|
||||
fmt.setRightMargin(6)
|
||||
cursor.setBlockFormat(fmt)
|
||||
|
||||
block = block.next()
|
||||
|
||||
def highlightBlock(self, text: str):
|
||||
"""Apply formatting to a block of text based on markdown syntax."""
|
||||
if not text:
|
||||
|
|
@ -244,12 +210,6 @@ class MarkdownEditor(QTextEdit):
|
|||
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||
|
||||
# Checkbox characters (Unicode for display, markdown for storage)
|
||||
_CHECK_UNCHECKED_DISPLAY = "☐"
|
||||
_CHECK_CHECKED_DISPLAY = "☑"
|
||||
_CHECK_UNCHECKED_STORAGE = "[ ]"
|
||||
_CHECK_CHECKED_STORAGE = "[x]"
|
||||
|
||||
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
|
@ -267,6 +227,12 @@ class MarkdownEditor(QTextEdit):
|
|||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
|
||||
# Checkbox characters (Unicode for display, markdown for storage)
|
||||
self._CHECK_UNCHECKED_DISPLAY = "☐"
|
||||
self._CHECK_CHECKED_DISPLAY = "☑"
|
||||
self._CHECK_UNCHECKED_STORAGE = "[ ]"
|
||||
self._CHECK_CHECKED_STORAGE = "[x]"
|
||||
|
||||
# Install syntax highlighter
|
||||
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
|
||||
|
||||
|
|
@ -300,12 +266,16 @@ class MarkdownEditor(QTextEdit):
|
|||
line = block.text()
|
||||
pos_in_block = c.position() - block.position()
|
||||
|
||||
# Transform only this line:
|
||||
# - "TODO " at start (with optional indent) -> "- ☐ "
|
||||
# - "- [ ] " -> " ☐ " and "- [x] " -> " ☑ "
|
||||
# Transform markldown checkboxes and 'TODO' to unicode checkboxes
|
||||
def transform_line(s: str) -> str:
|
||||
s = s.replace("- [x] ", f"{self._CHECK_CHECKED_DISPLAY} ")
|
||||
s = s.replace("- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
||||
s = s.replace(
|
||||
f"- {self._CHECK_CHECKED_STORAGE} ",
|
||||
f"{self._CHECK_CHECKED_DISPLAY} ",
|
||||
)
|
||||
s = s.replace(
|
||||
f"- {self._CHECK_UNCHECKED_STORAGE} ",
|
||||
f"{self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
)
|
||||
s = re.sub(
|
||||
r"^([ \t]*)TODO\b[:\-]?\s+",
|
||||
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
|
|
@ -332,13 +302,17 @@ class MarkdownEditor(QTextEdit):
|
|||
self._updating = False
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Export current content as markdown (convert Unicode checkboxes back to markdown)."""
|
||||
"""Export current content as markdown."""
|
||||
# First, extract any embedded images and convert to markdown
|
||||
text = self._extract_images_to_markdown()
|
||||
|
||||
# Convert Unicode checkboxes back to markdown syntax
|
||||
text = text.replace(f"{self._CHECK_CHECKED_DISPLAY} ", "- [x] ")
|
||||
text = text.replace(f"{self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
|
||||
text = text.replace(
|
||||
f"{self._CHECK_CHECKED_DISPLAY} ", f"- {self._CHECK_CHECKED_STORAGE} "
|
||||
)
|
||||
text = text.replace(
|
||||
f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} "
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
|
|
@ -377,13 +351,13 @@ class MarkdownEditor(QTextEdit):
|
|||
return "\n".join(result)
|
||||
|
||||
def from_markdown(self, markdown_text: str):
|
||||
"""Load markdown text into the editor (convert markdown checkboxes to Unicode)."""
|
||||
"""Load markdown text into the editor."""
|
||||
# Convert markdown checkboxes to Unicode for display
|
||||
display_text = markdown_text.replace(
|
||||
"- [x] ", f"{self._CHECK_CHECKED_DISPLAY} "
|
||||
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
|
||||
)
|
||||
display_text = display_text.replace(
|
||||
"- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||||
f"- {self._CHECK_UNCHECKED_STORAGE} ", f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||||
)
|
||||
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
|
||||
display_text = re.sub(
|
||||
|
|
@ -420,40 +394,34 @@ class MarkdownEditor(QTextEdit):
|
|||
mime_type = match.group(2)
|
||||
b64_data = match.group(3)
|
||||
|
||||
try:
|
||||
# Decode base64 to image
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
image = QImage.fromData(img_bytes)
|
||||
# Decode base64 to image
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
image = QImage.fromData(img_bytes)
|
||||
|
||||
if image.isNull():
|
||||
continue
|
||||
|
||||
# Use original image size - no scaling
|
||||
original_width = image.width()
|
||||
original_height = image.height()
|
||||
|
||||
# Create image format with original base64
|
||||
img_format = QTextImageFormat()
|
||||
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||||
img_format.setWidth(original_width)
|
||||
img_format.setHeight(original_height)
|
||||
|
||||
# Add image to document resources
|
||||
self.document().addResource(
|
||||
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||||
)
|
||||
|
||||
# Replace markdown with rendered image
|
||||
cursor = QTextCursor(self.document())
|
||||
cursor.setPosition(match.start())
|
||||
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
|
||||
cursor.insertImage(img_format)
|
||||
|
||||
except Exception as e:
|
||||
# If image fails to render, leave the markdown as-is
|
||||
print(f"Failed to render image: {e}")
|
||||
if image.isNull():
|
||||
continue
|
||||
|
||||
# Use original image size - no scaling
|
||||
original_width = image.width()
|
||||
original_height = image.height()
|
||||
|
||||
# Create image format with original base64
|
||||
img_format = QTextImageFormat()
|
||||
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||||
img_format.setWidth(original_width)
|
||||
img_format.setHeight(original_height)
|
||||
|
||||
# Add image to document resources
|
||||
self.document().addResource(
|
||||
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||||
)
|
||||
|
||||
# Replace markdown with rendered image
|
||||
cursor = QTextCursor(self.document())
|
||||
cursor.setPosition(match.start())
|
||||
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
|
||||
cursor.insertImage(img_format)
|
||||
|
||||
def _get_current_line(self) -> str:
|
||||
"""Get the text of the current line."""
|
||||
cursor = self.textCursor()
|
||||
|
|
@ -616,9 +584,8 @@ class MarkdownEditor(QTextEdit):
|
|||
def char_rect_at(doc_pos, ch):
|
||||
c = QTextCursor(self.document())
|
||||
c.setPosition(doc_pos)
|
||||
start_rect = self.cursorRect(
|
||||
c
|
||||
) # caret rect at char start (viewport coords)
|
||||
# caret rect at char start (viewport coords)
|
||||
start_rect = self.cursorRect(c)
|
||||
|
||||
# Use the actual font at this position for an accurate width
|
||||
fmt_font = (
|
||||
|
|
@ -638,9 +605,8 @@ class MarkdownEditor(QTextEdit):
|
|||
icon = self._CHECK_CHECKED_DISPLAY
|
||||
|
||||
if icon:
|
||||
doc_pos = (
|
||||
block.position() + i
|
||||
) # absolute document position of the icon
|
||||
# absolute document position of the icon
|
||||
doc_pos = block.position() + i
|
||||
r = char_rect_at(doc_pos, icon)
|
||||
|
||||
if r.contains(pt):
|
||||
|
|
@ -653,9 +619,10 @@ class MarkdownEditor(QTextEdit):
|
|||
edit = QTextCursor(self.document())
|
||||
edit.beginEditBlock()
|
||||
edit.setPosition(doc_pos)
|
||||
# icon + space
|
||||
edit.movePosition(
|
||||
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
|
||||
) # icon + space
|
||||
)
|
||||
edit.insertText(f"{new_icon} ")
|
||||
edit.endEditBlock()
|
||||
return # handled
|
||||
|
|
@ -745,7 +712,7 @@ class MarkdownEditor(QTextEdit):
|
|||
if cursor.hasSelection():
|
||||
# Wrap selection in code fence
|
||||
selected = cursor.selectedText()
|
||||
# Note: selectedText() uses Unicode paragraph separator, replace with newline
|
||||
# selectedText() uses Unicode paragraph separator, replace with newline
|
||||
selected = selected.replace("\u2029", "\n")
|
||||
new_text = f"```\n{selected}\n```"
|
||||
cursor.insertText(new_text)
|
||||
|
|
@ -881,7 +848,7 @@ class MarkdownEditor(QTextEdit):
|
|||
if not path.exists():
|
||||
return
|
||||
|
||||
# Read the ORIGINAL image file bytes for base64 encoding
|
||||
# Read the original image file bytes for base64 encoding
|
||||
with open(path, "rb") as f:
|
||||
img_data = f.read()
|
||||
|
||||
|
|
@ -905,17 +872,13 @@ class MarkdownEditor(QTextEdit):
|
|||
if image.isNull():
|
||||
return
|
||||
|
||||
# Use ORIGINAL size - no scaling!
|
||||
original_width = image.width()
|
||||
original_height = image.height()
|
||||
|
||||
# Create image format with original base64
|
||||
img_format = QTextImageFormat()
|
||||
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||||
img_format.setWidth(original_width)
|
||||
img_format.setHeight(original_height)
|
||||
img_format.setWidth(image.width())
|
||||
img_format.setHeight(image.height())
|
||||
|
||||
# Add ORIGINAL image to document resources
|
||||
# Add original image to document resources
|
||||
self.document().addResource(
|
||||
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,10 +66,7 @@ class Search(QWidget):
|
|||
self.resultDatesChanged.emit([]) # clear highlights
|
||||
return
|
||||
|
||||
try:
|
||||
rows: Iterable[Row] = self._db.search_entries(q)
|
||||
except Exception:
|
||||
rows = []
|
||||
rows: Iterable[Row] = self._db.search_entries(q)
|
||||
|
||||
self._populate_results(q, rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ class SettingsDialog(QDialog):
|
|||
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
||||
# Explanation for idle option (autolock)
|
||||
self.idle_spin_label = QLabel(
|
||||
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
||||
"Bouquin will automatically lock the notepad after this length of time, "
|
||||
"after which you'll need to re-enter the key to unlock it. "
|
||||
"Set to 0 (never) to never lock."
|
||||
)
|
||||
self.idle_spin_label.setWordWrap(True)
|
||||
|
|
@ -198,7 +199,7 @@ class SettingsDialog(QDialog):
|
|||
self.compact_label.setPalette(cpal)
|
||||
|
||||
maint_row = QHBoxLayout()
|
||||
maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
|
||||
maint_row.setContentsMargins(24, 0, 0, 0)
|
||||
maint_row.addWidget(self.compact_label)
|
||||
maint.addLayout(maint_row)
|
||||
|
||||
|
|
@ -270,7 +271,7 @@ class SettingsDialog(QDialog):
|
|||
self, "Key changed", "The notebook was re-encrypted with the new key!"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
||||
QMessageBox.critical(self, "Error", e)
|
||||
|
||||
@Slot(bool)
|
||||
def _save_key_btn_clicked(self, checked: bool):
|
||||
|
|
@ -291,11 +292,9 @@ class SettingsDialog(QDialog):
|
|||
def _compact_btn_clicked(self):
|
||||
try:
|
||||
self._db.compact()
|
||||
QMessageBox.information(
|
||||
self, "Compact complete", "Database compacted successfully!"
|
||||
)
|
||||
QMessageBox.information(self, "Success", "Database compacted successfully!")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
|
||||
QMessageBox.critical(self, "Error", e)
|
||||
|
||||
@property
|
||||
def config(self) -> DBConfig:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import pytest
|
||||
import json, csv
|
||||
import datetime as dt
|
||||
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
from bouquin.db import DBManager
|
||||
|
||||
|
||||
def _today():
|
||||
return dt.date.today().isoformat()
|
||||
|
||||
|
|
@ -101,7 +102,6 @@ def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
|
|||
fresh_db.rekey("new-key-123")
|
||||
fresh_db.close()
|
||||
|
||||
|
||||
tmp_db_cfg.key = "new-key-123"
|
||||
db2 = DBManager(tmp_db_cfg)
|
||||
assert db2.connect()
|
||||
|
|
@ -112,3 +112,57 @@ def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
|
|||
def test_compact_and_close_dont_crash(fresh_db):
|
||||
fresh_db.compact()
|
||||
fresh_db.close()
|
||||
|
||||
|
||||
def test_connect_integrity_failure(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
# simulate cursor() ok, but integrity check raising
|
||||
called = {"ok": False}
|
||||
|
||||
def bad_integrity(self):
|
||||
called["ok"] = True
|
||||
raise sqlite.Error("bad cipher")
|
||||
|
||||
monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True)
|
||||
ok = db.connect()
|
||||
assert not ok and called["ok"]
|
||||
assert db.conn is None
|
||||
|
||||
|
||||
def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
|
||||
# Monkeypatch connect() on the instance so the reconnect attempt fails
|
||||
def fail_connect():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(db, "connect", fail_connect, raising=False)
|
||||
with pytest.raises(sqlite.Error):
|
||||
db.rekey("newkey")
|
||||
|
||||
|
||||
def test_revert_wrong_date_raises(fresh_db):
|
||||
d1, d2 = "2024-01-01", "2024-01-02"
|
||||
v1_id, _ = fresh_db.save_new_version(d1, "one", "seed")
|
||||
fresh_db.save_new_version(d2, "two", "seed")
|
||||
with pytest.raises(ValueError):
|
||||
fresh_db.revert_to_version(d2, version_id=v1_id)
|
||||
|
||||
|
||||
def test_compact_error_path(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
|
||||
# Replace cursor.execute to raise to hit except branch
|
||||
class BadCur:
|
||||
def execute(self, *a, **k):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
class BadConn:
|
||||
def cursor(self):
|
||||
return BadCur()
|
||||
|
||||
db.conn = BadConn()
|
||||
# Should not raise; just print error
|
||||
db.compact()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from bouquin.markdown_editor import MarkdownEditor
|
|||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.find_bar import FindBar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
|
@ -51,3 +52,84 @@ def test_show_bar_seeds_selection(qtbot, editor):
|
|||
fb.show_bar()
|
||||
assert fb.edit.text().lower() == "alpha"
|
||||
fb.hide_bar()
|
||||
|
||||
|
||||
def test_show_bar_no_editor(qtbot, app):
|
||||
fb = FindBar(lambda: None)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar() # should early return without crashing and not become visible
|
||||
assert not fb.isVisible()
|
||||
|
||||
|
||||
def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
|
||||
editor.from_markdown("alpha\n\nbeta")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
# Select across the paragraph separator U+2029 equivalent – select more than one block
|
||||
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar()
|
||||
assert fb.edit.text() == "" # should not seed with multi-paragraph
|
||||
fb.hide_bar()
|
||||
|
||||
|
||||
def test_find_wraps_and_bumps_caret(qtbot, editor):
|
||||
editor.from_markdown("alpha alpha alpha")
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.edit.setText("alpha")
|
||||
|
||||
# Select the first occurrence so caret bumping path triggers
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
|
||||
fb.find_next() # should bump to after current selection then find next
|
||||
sel = editor.textCursor().selectedText()
|
||||
assert sel.lower() == "alpha"
|
||||
|
||||
# Force wrap to start by moving cursor to end then searching next
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
fb.find_next() # triggers wrap-to-start path
|
||||
assert editor.textCursor().hasSelection()
|
||||
|
||||
|
||||
def test_update_highlight_clear_when_empty(qtbot, editor):
|
||||
editor.from_markdown("find me find me")
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.edit.setText("find")
|
||||
fb._update_highlight()
|
||||
assert editor.extraSelections() # some highlights present
|
||||
|
||||
fb.edit.setText("")
|
||||
fb._update_highlight() # should clear
|
||||
assert not editor.extraSelections()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_maybe_hide_and_wrap_prev(qtbot, editor):
|
||||
editor.setPlainText("a a a")
|
||||
fb = FindBar(editor=editor, shortcut_parent=editor)
|
||||
qtbot.addWidget(editor)
|
||||
qtbot.addWidget(fb)
|
||||
editor.show()
|
||||
fb.show()
|
||||
|
||||
fb.edit.setText("a")
|
||||
fb._update_highlight()
|
||||
|
||||
assert fb.isVisible()
|
||||
fb._maybe_hide()
|
||||
assert not fb.isVisible()
|
||||
|
||||
fb.show_bar()
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(c)
|
||||
fb.find_prev()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QWidget, QMessageBox
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
|
@ -17,3 +17,69 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
|||
dlg.list.setCurrentRow(1)
|
||||
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
|
||||
assert fresh_db.get_entry(d) == "v1"
|
||||
|
||||
|
||||
def test_history_dialog_no_selection_clears(qtbot, fresh_db):
|
||||
d = "2001-01-01"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Clear selection (no current item) and call slot
|
||||
dlg.list.setCurrentItem(None)
|
||||
dlg._on_select()
|
||||
assert dlg.preview.toPlainText() == ""
|
||||
assert dlg.diff.toPlainText() == ""
|
||||
assert not dlg.btn_revert.isEnabled()
|
||||
|
||||
|
||||
def test_history_dialog_revert_same_version_noop(qtbot, fresh_db):
|
||||
d = "2001-01-01"
|
||||
# Only one version; that's the current
|
||||
vid, _ = fresh_db.save_new_version(d, "seed", "note")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Pick the only item (current)
|
||||
dlg.list.setCurrentRow(0)
|
||||
# Clicking revert should simply return (no change)
|
||||
before = fresh_db.get_entry(d)
|
||||
dlg._revert()
|
||||
after = fresh_db.get_entry(d)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
|
||||
d = "2001-01-02"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Select the row
|
||||
dlg.list.setCurrentRow(0)
|
||||
|
||||
# Monkeypatch db to raise inside revert_to_version to hit except path
|
||||
def boom(date_iso, version_id):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
dlg._db.revert_to_version = boom
|
||||
|
||||
# Auto-accept any QMessageBox that appears
|
||||
def _pump():
|
||||
for m in QMessageBox.instances():
|
||||
m.accept()
|
||||
|
||||
t = QTimer()
|
||||
t.setInterval(10)
|
||||
t.timeout.connect(_pump)
|
||||
t.start()
|
||||
try:
|
||||
dlg._revert()
|
||||
finally:
|
||||
t.stop()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import importlib
|
||||
import runpy
|
||||
import pytest
|
||||
|
||||
|
||||
def test_main_module_has_main():
|
||||
|
|
@ -9,3 +11,84 @@ def test_main_module_has_main():
|
|||
def test_dunder_main_imports_main():
|
||||
m = importlib.import_module("bouquin.__main__")
|
||||
assert hasattr(m, "main")
|
||||
|
||||
|
||||
def test_dunder_main_calls_main(monkeypatch):
|
||||
called = {"ok": False}
|
||||
|
||||
def fake_main():
|
||||
called["ok"] = True
|
||||
|
||||
# Replace real main with a stub to avoid launching Qt event loop
|
||||
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
|
||||
# Ensure that when __main__ imports from .main it gets our stub
|
||||
import bouquin.main as real_main
|
||||
|
||||
monkeypatch.setattr(real_main, "main", fake_main, raising=True)
|
||||
# Execute the module as a script
|
||||
runpy.run_module("bouquin.__main__", run_name="__main__")
|
||||
assert called["ok"]
|
||||
|
||||
|
||||
def test_main_creates_and_shows(monkeypatch):
|
||||
# Create a fake QApplication with the minimal API
|
||||
class FakeApp:
|
||||
def __init__(self, argv):
|
||||
self.ok = True
|
||||
|
||||
def setApplicationName(self, *_):
|
||||
pass
|
||||
|
||||
def setOrganizationName(self, *_):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return 0
|
||||
|
||||
class FakeWin:
|
||||
def __init__(self, themes=None):
|
||||
self.shown = False
|
||||
|
||||
def show(self):
|
||||
self.shown = True
|
||||
|
||||
class FakeSettings:
|
||||
def value(self, k, default=None):
|
||||
return "light" if k == "ui/theme" else default
|
||||
|
||||
# Patch imports inside bouquin.main
|
||||
import bouquin.main as m
|
||||
|
||||
monkeypatch.setattr(m, "QApplication", FakeApp, raising=True)
|
||||
monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True)
|
||||
|
||||
# Theme classes
|
||||
class FakeTM:
|
||||
def __init__(self, app, cfg):
|
||||
pass
|
||||
|
||||
def apply(self, theme):
|
||||
pass
|
||||
|
||||
class FakeTheme:
|
||||
def __init__(self, s):
|
||||
pass
|
||||
|
||||
class FakeCfg:
|
||||
def __init__(self, theme):
|
||||
self.theme = theme
|
||||
|
||||
monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True)
|
||||
monkeypatch.setattr(m, "Theme", FakeTheme, raising=True)
|
||||
monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True)
|
||||
|
||||
# get_settings() used inside main()
|
||||
def fake_get_settings():
|
||||
return FakeSettings()
|
||||
|
||||
monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True)
|
||||
|
||||
# Run
|
||||
with pytest.raises(SystemExit) as e:
|
||||
m.main()
|
||||
assert e.value.code == 0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import pytest
|
||||
from PySide6.QtCore import QDate
|
||||
|
||||
import bouquin.main_window as mwmod
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtCore import QEvent, QDate, QTimer
|
||||
from PySide6.QtWidgets import QTableView, QApplication
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
|
|
@ -71,3 +71,342 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
assert "carry me" in w.editor.to_markdown()
|
||||
y_txt = fresh_db.get_entry(y)
|
||||
assert "carry me" not in y_txt or "- [ ]" not in y_txt
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Force QDesktopServices.openUrl to fail so the warning path executes
|
||||
called = {"docs": False, "bugs": False}
|
||||
|
||||
def fake_open(url):
|
||||
# return False to force warning path
|
||||
return False
|
||||
|
||||
mwmod.QDesktopServices.openUrl = fake_open # minimal monkeypatch
|
||||
|
||||
class DummyMB:
|
||||
@staticmethod
|
||||
def warning(parent, title, text, *rest):
|
||||
t = str(text)
|
||||
if "wiki" in t:
|
||||
called["docs"] = True
|
||||
if "forms/mig5/contact" in t or "contact" in t:
|
||||
called["bugs"] = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
|
||||
|
||||
# Trigger both actions
|
||||
w._open_docs()
|
||||
w._open_bugs()
|
||||
assert called["docs"] and called["bugs"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
# Seed some content
|
||||
fresh_db.save_new_version("2001-01-01", "alpha", "n1")
|
||||
fresh_db.save_new_version("2001-01-02", "beta", "n2")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Save as Markdown without extension -> should append .md and write file
|
||||
dest1 = tmp_path / "export_one" # no suffix
|
||||
|
||||
def fake_save1(*a, **k):
|
||||
return str(dest1), "Markdown (*.md)"
|
||||
|
||||
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save1))
|
||||
|
||||
info_log = {"ok": False}
|
||||
|
||||
# Auto-accept the warning dialog
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
|
||||
)
|
||||
info_log = {"ok": False}
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox,
|
||||
"information",
|
||||
staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox,
|
||||
"critical",
|
||||
staticmethod(
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical"))
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
w._export()
|
||||
assert dest1.with_suffix(".md").exists()
|
||||
assert info_log["ok"]
|
||||
|
||||
# Now force an exception during export to hit error branch (patch the window's DB)
|
||||
def boom():
|
||||
raise RuntimeError("explode")
|
||||
|
||||
monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False)
|
||||
|
||||
# Different filename to avoid overwriting
|
||||
dest2 = tmp_path / "export_two"
|
||||
|
||||
def fake_save2(*a, **k):
|
||||
return str(dest2), "CSV (*.csv)"
|
||||
|
||||
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2))
|
||||
|
||||
errs = {"hit": False}
|
||||
# Auto-accept the warning dialog and capture the error message
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox,
|
||||
"critical",
|
||||
staticmethod(lambda *a, **k: errs.__setitem__("hit", True) or 0),
|
||||
raising=False,
|
||||
)
|
||||
w._export()
|
||||
assert errs["hit"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
# wire DB settings the window reads
|
||||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Pretend user picked a filename with no suffix -> .db should be appended
|
||||
dest = tmp_path / "backupfile"
|
||||
monkeypatch.setattr(
|
||||
mwmod.QFileDialog,
|
||||
"getSaveFileName",
|
||||
staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
# Avoid any modal dialogs and record the success message
|
||||
hit = {"info": False, "text": None}
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox,
|
||||
"information",
|
||||
staticmethod(
|
||||
lambda parent, title, text, *a, **k: (
|
||||
hit.__setitem__("info", True),
|
||||
hit.__setitem__("text", str(text)),
|
||||
0,
|
||||
)[-1]
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
|
||||
)
|
||||
|
||||
# Stub the *export* itself to be instant and non-blocking
|
||||
called = {"path": None}
|
||||
monkeypatch.setattr(
|
||||
w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False
|
||||
)
|
||||
|
||||
w._backup()
|
||||
|
||||
# Assertions: suffix added, export invoked, success toast shown
|
||||
assert called["path"] == str(dest.with_suffix(".db"))
|
||||
assert hit["info"]
|
||||
assert str(dest.with_suffix(".db")) in hit["text"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Create exactly one extra tab (there is already one from __init__)
|
||||
d1 = QDate(2020, 1, 1)
|
||||
w._open_date_in_tab(d1)
|
||||
assert w.tab_widget.count() == 2
|
||||
|
||||
# Close one tab: should call _save_editor_content on its editor
|
||||
saved = {"called": False}
|
||||
|
||||
def fake_save_editor(editor):
|
||||
saved["called"] = True
|
||||
|
||||
monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True)
|
||||
w._close_tab(0)
|
||||
assert saved["called"]
|
||||
# Now only one tab remains; closing should no-op
|
||||
count_before = w.tab_widget.count()
|
||||
w._close_tab(0)
|
||||
assert w.tab_widget.count() == count_before
|
||||
monkeypatch.delattr(w, "_save_editor_content", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Case A: no geometry stored -> should call _move_to_cursor_screen_center
|
||||
moved = {"hit": False}
|
||||
monkeypatch.setattr(
|
||||
w,
|
||||
"_move_to_cursor_screen_center",
|
||||
lambda: moved.__setitem__("hit", True),
|
||||
raising=True,
|
||||
)
|
||||
# clear any stored geometry
|
||||
w.settings.remove("main/geometry")
|
||||
w.settings.remove("main/windowState")
|
||||
w.settings.remove("main/maximized")
|
||||
w._restore_window_position()
|
||||
assert moved["hit"]
|
||||
|
||||
# Case B: geometry present but off-screen -> fallback to move_to_cursor
|
||||
moved["hit"] = False
|
||||
# Save a valid geometry then lie that it's offscreen
|
||||
geom = w.saveGeometry()
|
||||
w.settings.setValue("main/geometry", geom)
|
||||
w.settings.setValue("main/windowState", w.saveState())
|
||||
w.settings.setValue("main/maximized", False)
|
||||
monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True)
|
||||
w._restore_window_position()
|
||||
assert moved["hit"]
|
||||
|
||||
# Case C: was_max True triggers showMaximized via QTimer.singleShot
|
||||
called = {"max": False}
|
||||
monkeypatch.setattr(
|
||||
w, "showMaximized", lambda: called.__setitem__("max", True), raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False
|
||||
)
|
||||
w.settings.setValue("main/maximized", True)
|
||||
w._restore_window_position()
|
||||
assert called["max"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
|
||||
# Seed DB so refresh marks does something
|
||||
fresh_db.save_new_version("2021-08-15", "note", "")
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Show a month with leading days
|
||||
qd = QDate(2021, 8, 15)
|
||||
w.calendar.setSelectedDate(qd)
|
||||
|
||||
# Grab the internal table view and pick a couple of indices
|
||||
view = w.calendar.findChild(QTableView, "qt_calendar_calendarview")
|
||||
model = view.model()
|
||||
|
||||
# Find the first index belonging to current month (day == 1)
|
||||
first_idx = None
|
||||
for r in range(model.rowCount()):
|
||||
for c in range(model.columnCount()):
|
||||
if model.index(r, c).data() == 1:
|
||||
first_idx = model.index(r, c)
|
||||
break
|
||||
if first_idx:
|
||||
break
|
||||
|
||||
assert first_idx is not None
|
||||
|
||||
# A cell before 'first_idx' should map to previous month
|
||||
col0 = 0 if first_idx.column() > 0 else 1
|
||||
idx_prev = model.index(first_idx.row(), col0)
|
||||
vp_pos = view.visualRect(idx_prev).center()
|
||||
global_pos = view.viewport().mapToGlobal(vp_pos)
|
||||
cal_pos = w.calendar.mapFromGlobal(global_pos)
|
||||
date_prev = w._date_from_calendar_pos(cal_pos)
|
||||
assert isinstance(date_prev, QDate) and date_prev.isValid()
|
||||
|
||||
# A cell after the last day should map to next month
|
||||
last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day()
|
||||
last_idx = None
|
||||
for r in range(model.rowCount() - 1, -1, -1):
|
||||
for c in range(model.columnCount() - 1, -1, -1):
|
||||
if model.index(r, c).data() == last_day:
|
||||
last_idx = model.index(r, c)
|
||||
break
|
||||
if last_idx:
|
||||
break
|
||||
assert last_idx is not None
|
||||
|
||||
c_next = min(model.columnCount() - 1, last_idx.column() + 1)
|
||||
idx_next = model.index(last_idx.row(), c_next)
|
||||
vp_pos2 = view.visualRect(idx_next).center()
|
||||
global_pos2 = view.viewport().mapToGlobal(vp_pos2)
|
||||
cal_pos2 = w.calendar.mapFromGlobal(global_pos2)
|
||||
date_next = w._date_from_calendar_pos(cal_pos2)
|
||||
assert isinstance(date_next, QDate) and date_next.isValid()
|
||||
|
||||
# Context menu path: return the "Open in New Tab" action
|
||||
class DummyMenu:
|
||||
def __init__(self, parent=None):
|
||||
self._action = object()
|
||||
|
||||
def addAction(self, text):
|
||||
return self._action
|
||||
|
||||
def exec_(self, *args, **kwargs):
|
||||
return self._action
|
||||
|
||||
monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
|
||||
w._show_calendar_context_menu(cal_pos)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_event_filter_keypress_starts_idle_timer(qtbot, app):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
ev = QEvent(QEvent.KeyPress)
|
||||
w.eventFilter(w, ev)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from PySide6.QtGui import QImage, QColor
|
||||
from PySide6.QtCore import Qt, QPoint
|
||||
from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
|
@ -61,3 +62,84 @@ def test_apply_code_inline(editor):
|
|||
editor.apply_code()
|
||||
md = editor.to_markdown()
|
||||
assert ("`" in md) or ("```" in md)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_auto_close_code_fence(editor, qtbot):
|
||||
# Place caret at start and type exactly `` then ` to trigger expansion
|
||||
editor.setPlainText("")
|
||||
qtbot.keyClicks(editor, "``")
|
||||
qtbot.keyClicks(editor, "`") # third backtick triggers fence insertion
|
||||
txt = editor.toPlainText()
|
||||
assert "```" in txt and txt.count("```") >= 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_checkbox_toggle_by_click(editor, qtbot):
|
||||
# Load a markdown checkbox
|
||||
editor.from_markdown("- [ ] task here")
|
||||
# Verify display token present
|
||||
display = editor.toPlainText()
|
||||
assert "☐" in display
|
||||
|
||||
# Click on the first character region to toggle
|
||||
c = editor.textCursor()
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
c.movePosition(QTextCursor.StartOfBlock)
|
||||
editor.setTextCursor(c)
|
||||
r = editor.cursorRect()
|
||||
center = r.center()
|
||||
# Send click slightly right to land within checkbox icon region
|
||||
pos = QPoint(r.left() + 2, center.y())
|
||||
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
|
||||
|
||||
# Should have toggled to checked icon
|
||||
display2 = editor.toPlainText()
|
||||
assert "☑" in display2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_apply_heading_levels(editor, qtbot):
|
||||
editor.setPlainText("hello")
|
||||
editor.selectAll()
|
||||
# H2
|
||||
editor.apply_heading(18)
|
||||
assert editor.toPlainText().startswith("## ")
|
||||
# H3
|
||||
editor.selectAll()
|
||||
editor.apply_heading(14)
|
||||
assert editor.toPlainText().startswith("### ")
|
||||
# Normal (no heading)
|
||||
editor.selectAll()
|
||||
editor.apply_heading(12)
|
||||
assert not editor.toPlainText().startswith("#")
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_enter_on_nonempty_list_continues(qtbot, editor):
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.from_markdown("- item")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(ev)
|
||||
txt = editor.toPlainText()
|
||||
assert "\n- " in txt
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_enter_on_empty_list_marks_empty(qtbot, editor):
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.from_markdown("- ")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(ev)
|
||||
assert editor.toPlainText().startswith("- \n")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import pytest
|
||||
from bouquin.search import Search
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
|
||||
def test_search_widget_populates_results(qtbot, fresh_db):
|
||||
|
|
@ -20,3 +22,82 @@ def test_search_widget_populates_results(qtbot, fresh_db):
|
|||
s.search.setText("")
|
||||
qtbot.wait(50)
|
||||
assert s.results.isHidden()
|
||||
|
||||
|
||||
def test_open_selected_with_data(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
seen = []
|
||||
s.openDateRequested.connect(lambda d: seen.append(d))
|
||||
it = QListWidgetItem("dummy")
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
|
||||
s.results.addItem(it)
|
||||
s._open_selected(it)
|
||||
assert seen == ["1999-12-31"]
|
||||
|
||||
|
||||
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
long = (
|
||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||
)
|
||||
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||
assert "alpha" in frag
|
||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||
|
||||
|
||||
def test_open_selected_ignores_no_data(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
seen = []
|
||||
s.openDateRequested.connect(lambda d: seen.append(d))
|
||||
it = QListWidgetItem("dummy")
|
||||
# No UserRole data set -> should not emit
|
||||
s._open_selected(it)
|
||||
assert not seen
|
||||
|
||||
|
||||
def test_make_html_snippet_variants(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
|
||||
src = " ".join(["word"] * 200)
|
||||
frag, left, right = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
|
||||
assert frag and not left and right
|
||||
|
||||
# Case: multiple tokens highlighted
|
||||
src = "Alpha bravo charlie delta echo"
|
||||
frag, left, right = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
|
||||
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
|
||||
assert "<b>delta</b>" in frag
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
s.search.setText("alpha")
|
||||
|
||||
frag, left, right = s._make_html_snippet("", "alpha", radius=10, maxlen=40)
|
||||
assert frag == "" and not left and not right
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
long = "X" * 40 + "alpha" + "Y" * 40
|
||||
rows = [("2000-01-01", long)]
|
||||
s._populate_results("alpha", rows)
|
||||
assert s.results.count() >= 1
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
from bouquin.search import Search
|
||||
|
||||
|
||||
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
long = (
|
||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||
)
|
||||
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||
assert "alpha" in frag
|
||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
from pathlib import Path
|
||||
from bouquin.settings import (
|
||||
get_settings,
|
||||
load_db_config,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import pytest
|
||||
import bouquin.settings_dialog as sd
|
||||
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
|
|
@ -6,6 +8,7 @@ from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
|||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
|
||||
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||
|
|
@ -161,3 +164,64 @@ def test_change_key_success(qtbot, tmp_path, app):
|
|||
assert db2.connect()
|
||||
assert "seed" in db2.get_entry("2001-01-01")
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
|
||||
# Parent with ThemeManager (dialog uses parent().themes.set(...))
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Monkeypatch db.compact to raise
|
||||
def boom():
|
||||
raise RuntimeError("nope")
|
||||
|
||||
dlg._db.compact = boom # type: ignore
|
||||
|
||||
called = {"critical": False, "title": None, "text": None}
|
||||
|
||||
class DummyMB:
|
||||
@staticmethod
|
||||
def information(*args, **kwargs):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def critical(parent, title, text, *rest):
|
||||
called["critical"] = True
|
||||
called["title"] = title
|
||||
called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
# Swap QMessageBox used inside the dialog module so signature mismatch can't occur
|
||||
monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
|
||||
|
||||
# Invoke
|
||||
dlg._compact_btn_clicked()
|
||||
|
||||
assert called["critical"]
|
||||
assert called["title"]
|
||||
assert called["text"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "x.db", key="k", idle_minutes=0, theme="light", move_todos=True
|
||||
)
|
||||
dlg = SettingsDialog(cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
p = tmp_path / "new_file.db"
|
||||
monkeypatch.setattr(
|
||||
sd.QFileDialog,
|
||||
"getSaveFileName",
|
||||
staticmethod(lambda *a, **k: (str(p), "DB Files (*.db)")),
|
||||
raising=False,
|
||||
)
|
||||
dlg._browse()
|
||||
assert dlg.path_edit.text().endswith("new_file.db")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ def editor(app, qtbot):
|
|||
ed.show()
|
||||
return ed
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toolbar_signals_and_styling(qtbot, editor):
|
||||
host = QWidget()
|
||||
|
|
@ -39,3 +40,29 @@ def test_toolbar_signals_and_styling(qtbot, editor):
|
|||
tb.strikeRequested.emit()
|
||||
tb.headingRequested.emit(24)
|
||||
assert editor.to_markdown()
|
||||
|
||||
|
||||
def test_style_letter_button_paths(app, qtbot):
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
# Create toolbar
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
tb = ToolBar(parent)
|
||||
qtbot.addWidget(tb)
|
||||
|
||||
# Action not added to toolbar -> no widget, early return
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
stray = QAction("Stray", tb)
|
||||
tb._style_letter_button(stray, "Z") # should not raise
|
||||
|
||||
# Now add an action to toolbar and style with tooltip
|
||||
act = tb.addAction("Temp")
|
||||
tb._style_letter_button(act, "T", tooltip="Tip here")
|
||||
btn = tb.widgetForAction(act)
|
||||
assert btn.toolTip() == "Tip here"
|
||||
assert btn.accessibleName() == "Tip here"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue