Code cleanup, more tests

This commit is contained in:
Miguel Jacq 2025-11-11 13:12:30 +11:00
parent 1c0052a0cf
commit bfd0314109
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 1212 additions and 478 deletions

View file

@ -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 (&nbsp; 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:

View file

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

View file

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

View file

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

View file

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