bouquin 0.1.7__tar.gz → 0.2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.7
3
+ Version: 0.2.0.1
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  ## Introduction
25
25
 
26
- Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
26
+ Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
27
27
 
28
28
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
29
29
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -37,25 +37,36 @@ There is deliberately no network connectivity or syncing intended.
37
37
 
38
38
  ![Screenshot of Bouquin](./screenshot.png)
39
39
 
40
+ ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
41
+
40
42
  ## Features
41
43
 
42
44
  * Data is encrypted at rest
43
45
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
44
46
  * Every 'page' is linked to the calendar day
45
47
  * All changes are version controlled, with ability to view/diff versions and revert
46
- * Text is HTML with basic styling
48
+ * Text is Markdown with basic styling
49
+ * Images are supported
47
50
  * Search
48
51
  * Automatic periodic saving (or explicitly save)
49
52
  * Transparent integrity checking of the database when it opens
50
53
  * Automatic locking of the app after a period of inactivity (default 15 min)
51
54
  * Rekey the database (change the password)
52
- * Export the database to json, txt, html or csv
55
+ * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
56
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
57
+ * Dark and light themes
58
+ * Automatically generate checkboxes when typing 'TODO'
59
+ * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
53
60
 
54
61
 
55
62
  ## How to install
56
63
 
57
64
  Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
58
65
 
66
+ ### From PyPi/pip
67
+
68
+ * `pip install bouquin`
69
+
59
70
  ### From source
60
71
 
61
72
  * Clone this repo or download the tarball from the releases page
@@ -67,15 +78,10 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
67
78
 
68
79
  * Download the whl and run it
69
80
 
70
- ### From PyPi/pip
71
-
72
- * `pip install bouquin`
73
-
74
-
75
81
  ## How to run the tests
76
82
 
77
83
  * Clone the repo
78
84
  * Ensure you have poetry installed
79
85
  * Run `poetry install --with test`
80
- * Run `poetry run pytest -vvv`
86
+ * Run `./tests.sh`
81
87
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  ## Introduction
5
5
 
6
- Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
6
+ Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
7
7
 
8
8
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
9
9
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -17,25 +17,36 @@ There is deliberately no network connectivity or syncing intended.
17
17
 
18
18
  ![Screenshot of Bouquin](./screenshot.png)
19
19
 
20
+ ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
21
+
20
22
  ## Features
21
23
 
22
24
  * Data is encrypted at rest
23
25
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
24
26
  * Every 'page' is linked to the calendar day
25
27
  * All changes are version controlled, with ability to view/diff versions and revert
26
- * Text is HTML with basic styling
28
+ * Text is Markdown with basic styling
29
+ * Images are supported
27
30
  * Search
28
31
  * Automatic periodic saving (or explicitly save)
29
32
  * Transparent integrity checking of the database when it opens
30
33
  * Automatic locking of the app after a period of inactivity (default 15 min)
31
34
  * Rekey the database (change the password)
32
- * Export the database to json, txt, html or csv
35
+ * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
36
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
37
+ * Dark and light themes
38
+ * Automatically generate checkboxes when typing 'TODO'
39
+ * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
33
40
 
34
41
 
35
42
  ## How to install
36
43
 
37
44
  Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
38
45
 
46
+ ### From PyPi/pip
47
+
48
+ * `pip install bouquin`
49
+
39
50
  ### From source
40
51
 
41
52
  * Clone this repo or download the tarball from the releases page
@@ -47,14 +58,9 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
47
58
 
48
59
  * Download the whl and run it
49
60
 
50
- ### From PyPi/pip
51
-
52
- * `pip install bouquin`
53
-
54
-
55
61
  ## How to run the tests
56
62
 
57
63
  * Clone the repo
58
64
  * Ensure you have poetry installed
59
65
  * Run `poetry install --with test`
60
- * Run `poetry run pytest -vvv`
66
+ * Run `./tests.sh`
File without changes
@@ -18,6 +18,8 @@ class DBConfig:
18
18
  path: Path
19
19
  key: str
20
20
  idle_minutes: int = 15 # 0 = never lock
21
+ theme: str = "system"
22
+ move_todos: bool = False
21
23
 
22
24
 
23
25
  class DBManager:
@@ -133,7 +135,7 @@ class DBManager:
133
135
  raise RuntimeError("Database is not connected")
134
136
  cur = self.conn.cursor()
135
137
  # Change the encryption key of the currently open database
136
- cur.execute(f"PRAGMA rekey = '{new_key}';")
138
+ cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
137
139
  self.conn.commit()
138
140
 
139
141
  # Close and reopen with the new key to verify and restore PRAGMAs
@@ -159,13 +161,6 @@ class DBManager:
159
161
  ).fetchone()
160
162
  return row[0] if row else ""
161
163
 
162
- def upsert_entry(self, date_iso: str, content: str) -> None:
163
- """
164
- Insert or update an entry.
165
- """
166
- # Make a new version and set it as current
167
- self.save_new_version(date_iso, content, note=None, set_current=True)
168
-
169
164
  def search_entries(self, text: str) -> list[str]:
170
165
  """
171
166
  Search for entries by term. This only works against the latest
@@ -261,68 +256,31 @@ class DBManager:
261
256
  ).fetchall()
262
257
  return [dict(r) for r in rows]
263
258
 
264
- def get_version(
265
- self,
266
- *,
267
- date_iso: str | None = None,
268
- version_no: int | None = None,
269
- version_id: int | None = None,
270
- ) -> dict | None:
259
+ def get_version(self, *, version_id: int) -> dict | None:
271
260
  """
272
- Fetch a specific version by (date, version_no) OR by version_id.
261
+ Fetch a specific version by version_id.
273
262
  Returns a dict with keys: id, date, version_no, created_at, note, content.
274
263
  """
275
264
  cur = self.conn.cursor()
276
- if version_id is not None:
277
- row = cur.execute(
278
- "SELECT id, date, version_no, created_at, note, content "
279
- "FROM versions WHERE id=?;",
280
- (version_id,),
281
- ).fetchone()
282
- else:
283
- if date_iso is None or version_no is None:
284
- raise ValueError(
285
- "Provide either version_id OR (date_iso and version_no)"
286
- )
287
- row = cur.execute(
288
- "SELECT id, date, version_no, created_at, note, content "
289
- "FROM versions WHERE date=? AND version_no=?;",
290
- (date_iso, version_no),
291
- ).fetchone()
265
+ row = cur.execute(
266
+ "SELECT id, date, version_no, created_at, note, content "
267
+ "FROM versions WHERE id=?;",
268
+ (version_id,),
269
+ ).fetchone()
292
270
  return dict(row) if row else None
293
271
 
294
- def revert_to_version(
295
- self,
296
- date_iso: str,
297
- *,
298
- version_no: int | None = None,
299
- version_id: int | None = None,
300
- ) -> None:
272
+ def revert_to_version(self, date_iso: str, version_id: int) -> None:
301
273
  """
302
274
  Point the page head (pages.current_version_id) to an existing version.
303
- Fast revert: no content is rewritten.
304
275
  """
305
- if self.conn is None:
306
- raise RuntimeError("Database is not connected")
307
276
  cur = self.conn.cursor()
308
277
 
309
- if version_id is None:
310
- if version_no is None:
311
- raise ValueError("Provide version_no or version_id")
312
- row = cur.execute(
313
- "SELECT id FROM versions WHERE date=? AND version_no=?;",
314
- (date_iso, version_no),
315
- ).fetchone()
316
- if row is None:
317
- raise ValueError("Version not found for this date")
318
- version_id = int(row["id"])
319
- else:
320
- # Ensure that version_id belongs to the given date
321
- row = cur.execute(
322
- "SELECT date FROM versions WHERE id=?;", (version_id,)
323
- ).fetchone()
324
- if row is None or row["date"] != date_iso:
325
- raise ValueError("version_id does not belong to the given date")
278
+ # Ensure that version_id belongs to the given date
279
+ row = cur.execute(
280
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
281
+ ).fetchone()
282
+ if row is None or row["date"] != date_iso:
283
+ raise ValueError("version_id does not belong to the given date")
326
284
 
327
285
  with self.conn:
328
286
  cur.execute(
@@ -346,20 +304,18 @@ class DBManager:
346
304
  ).fetchall()
347
305
  return [(r[0], r[1]) for r in rows]
348
306
 
349
- def export_json(
350
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
351
- ) -> None:
307
+ def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
352
308
  """
353
309
  Export to json.
354
310
  """
355
311
  data = [{"date": d, "content": c} for d, c in entries]
356
312
  with open(file_path, "w", encoding="utf-8") as f:
357
- if pretty:
358
- json.dump(data, f, ensure_ascii=False, indent=2)
359
- else:
360
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
313
+ json.dump(data, f, ensure_ascii=False, indent=2)
361
314
 
362
315
  def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
316
+ """
317
+ Export pages to CSV.
318
+ """
363
319
  # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
364
320
  with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
365
321
  writer = csv.writer(f)
@@ -373,6 +329,10 @@ class DBManager:
373
329
  separator: str = "\n\n— — — — —\n\n",
374
330
  strip_html: bool = True,
375
331
  ) -> None:
332
+ """
333
+ Strip the HTML from the latest version of the pages
334
+ and save to a text file.
335
+ """
376
336
  import re, html as _html
377
337
 
378
338
  # Precompiled patterns
@@ -409,8 +369,11 @@ class DBManager:
409
369
  f.write(separator)
410
370
 
411
371
  def export_html(
412
- self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
372
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
413
373
  ) -> None:
374
+ """
375
+ Export to HTML with a heading.
376
+ """
414
377
  parts = [
415
378
  "<!doctype html>",
416
379
  '<html lang="en">',
@@ -430,7 +393,45 @@ class DBManager:
430
393
  with open(file_path, "w", encoding="utf-8") as f:
431
394
  f.write("\n".join(parts))
432
395
 
396
+ def export_markdown(
397
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
398
+ ) -> None:
399
+ """
400
+ Export to HTML, similar to export_html, but then convert to Markdown
401
+ using markdownify, and finally save to file.
402
+ """
403
+ parts = []
404
+ for d, c in entries:
405
+ parts.append(f"# {d}")
406
+ parts.append(c)
407
+
408
+ with open(file_path, "w", encoding="utf-8") as f:
409
+ f.write("\n".join(parts))
410
+
411
+ def export_sql(self, file_path: str) -> None:
412
+ """
413
+ Exports the encrypted database as plaintext SQL.
414
+ """
415
+ cur = self.conn.cursor()
416
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS plaintext KEY '';")
417
+ cur.execute("SELECT sqlcipher_export('plaintext')")
418
+ cur.execute("DETACH DATABASE plaintext")
419
+
420
+ def export_sqlcipher(self, file_path: str) -> None:
421
+ """
422
+ Exports the encrypted database as an encrypted database with the same key.
423
+ Intended for Bouquin-compatible backups.
424
+ """
425
+ cur = self.conn.cursor()
426
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS backup KEY '{self.cfg.key}'")
427
+ cur.execute("SELECT sqlcipher_export('backup')")
428
+ cur.execute("DETACH DATABASE backup")
429
+
433
430
  def export_by_extension(self, file_path: str) -> None:
431
+ """
432
+ Fallback catch-all that runs one of the above functions based on
433
+ the extension of the file name that was chosen by the user.
434
+ """
434
435
  entries = self.get_all_entries()
435
436
  ext = os.path.splitext(file_path)[1].lower()
436
437
 
@@ -442,9 +443,23 @@ class DBManager:
442
443
  self.export_txt(entries, file_path)
443
444
  elif ext in {".html", ".htm"}:
444
445
  self.export_html(entries, file_path)
446
+ elif ext in {".sql", ".sqlite"}:
447
+ self.export_sql(file_path)
448
+ elif ext == ".md":
449
+ self.export_markdown(entries, file_path)
445
450
  else:
446
451
  raise ValueError(f"Unsupported extension: {ext}")
447
452
 
453
+ def compact(self) -> None:
454
+ """
455
+ Runs VACUUM on the db.
456
+ """
457
+ try:
458
+ cur = self.conn.cursor()
459
+ cur.execute("VACUUM")
460
+ except Exception as e:
461
+ print(f"Error: {e}")
462
+
448
463
  def close(self) -> None:
449
464
  if self.conn is not None:
450
465
  self.conn.close()
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtGui import (
5
+ QShortcut,
6
+ QTextCursor,
7
+ QTextCharFormat,
8
+ QTextDocument,
9
+ )
10
+ from PySide6.QtWidgets import (
11
+ QWidget,
12
+ QHBoxLayout,
13
+ QLineEdit,
14
+ QLabel,
15
+ QPushButton,
16
+ QCheckBox,
17
+ QTextEdit,
18
+ )
19
+
20
+
21
+ class FindBar(QWidget):
22
+ """Widget for finding text in the Editor"""
23
+
24
+ closed = (
25
+ Signal()
26
+ ) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
27
+
28
+ def __init__(
29
+ self,
30
+ editor: QTextEdit,
31
+ shortcut_parent: QWidget | None = None,
32
+ parent: QWidget | None = None,
33
+ ):
34
+ super().__init__(parent)
35
+ self.editor = editor
36
+
37
+ # UI
38
+ layout = QHBoxLayout(self)
39
+ layout.setContentsMargins(6, 0, 6, 0)
40
+
41
+ layout.addWidget(QLabel("Find:"))
42
+ self.edit = QLineEdit(self)
43
+ self.edit.setPlaceholderText("Type to search…")
44
+ layout.addWidget(self.edit)
45
+
46
+ self.case = QCheckBox("Match case", self)
47
+ layout.addWidget(self.case)
48
+
49
+ self.prevBtn = QPushButton("Prev", self)
50
+ self.nextBtn = QPushButton("Next", self)
51
+ self.closeBtn = QPushButton("✕", self)
52
+ self.closeBtn.setFlat(True)
53
+ layout.addWidget(self.prevBtn)
54
+ layout.addWidget(self.nextBtn)
55
+ layout.addWidget(self.closeBtn)
56
+
57
+ self.setVisible(False)
58
+
59
+ # Shortcut escape key to close findBar
60
+ sp = shortcut_parent if shortcut_parent is not None else (parent or self)
61
+ self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
62
+
63
+ # Signals
64
+ self.edit.returnPressed.connect(self.find_next)
65
+ self.edit.textChanged.connect(self._update_highlight)
66
+ self.case.toggled.connect(self._update_highlight)
67
+ self.nextBtn.clicked.connect(self.find_next)
68
+ self.prevBtn.clicked.connect(self.find_prev)
69
+ self.closeBtn.clicked.connect(self.hide_bar)
70
+
71
+ # ----- Public API -----
72
+
73
+ def show_bar(self):
74
+ """Show the bar, seed with current selection if sensible, focus the line edit."""
75
+ tc = self.editor.textCursor()
76
+ sel = tc.selectedText().strip()
77
+ if sel and "\u2029" not in sel: # ignore multi-paragraph selections
78
+ self.edit.setText(sel)
79
+ self.setVisible(True)
80
+ self.edit.setFocus(Qt.ShortcutFocusReason)
81
+ self.edit.selectAll()
82
+ self._update_highlight()
83
+
84
+ def hide_bar(self):
85
+ self.setVisible(False)
86
+ self._clear_highlight()
87
+ self.closed.emit()
88
+
89
+ def refresh(self):
90
+ """Recompute highlights"""
91
+ self._update_highlight()
92
+
93
+ # ----- Internals -----
94
+
95
+ def _maybe_hide(self):
96
+ if self.isVisible():
97
+ self.hide_bar()
98
+
99
+ def _flags(self, backward: bool = False) -> QTextDocument.FindFlags:
100
+ flags = QTextDocument.FindFlags()
101
+ if backward:
102
+ flags |= QTextDocument.FindBackward
103
+ if self.case.isChecked():
104
+ flags |= QTextDocument.FindCaseSensitively
105
+ return flags
106
+
107
+ def find_next(self):
108
+ txt = self.edit.text()
109
+ if not txt:
110
+ return
111
+ # If current selection == query, bump caret to the end so we don't re-match it.
112
+ c = self.editor.textCursor()
113
+ if c.hasSelection():
114
+ sel = c.selectedText()
115
+ same = (
116
+ (sel == txt)
117
+ if self.case.isChecked()
118
+ else (sel.casefold() == txt.casefold())
119
+ )
120
+ if same:
121
+ end = max(c.position(), c.anchor())
122
+ c.setPosition(end, QTextCursor.MoveAnchor)
123
+ self.editor.setTextCursor(c)
124
+ if not self.editor.find(txt, self._flags(False)):
125
+ cur = self.editor.textCursor()
126
+ cur.movePosition(QTextCursor.Start)
127
+ self.editor.setTextCursor(cur)
128
+ self.editor.find(txt, self._flags(False))
129
+ self.editor.ensureCursorVisible()
130
+ self._update_highlight()
131
+
132
+ def find_prev(self):
133
+ txt = self.edit.text()
134
+ if not txt:
135
+ return
136
+ # If current selection == query, bump caret to the start so we don't re-match it.
137
+ c = self.editor.textCursor()
138
+ if c.hasSelection():
139
+ sel = c.selectedText()
140
+ same = (
141
+ (sel == txt)
142
+ if self.case.isChecked()
143
+ else (sel.casefold() == txt.casefold())
144
+ )
145
+ if same:
146
+ start = min(c.position(), c.anchor())
147
+ c.setPosition(start, QTextCursor.MoveAnchor)
148
+ self.editor.setTextCursor(c)
149
+ if not self.editor.find(txt, self._flags(True)):
150
+ cur = self.editor.textCursor()
151
+ cur.movePosition(QTextCursor.End)
152
+ self.editor.setTextCursor(cur)
153
+ self.editor.find(txt, self._flags(True))
154
+ self.editor.ensureCursorVisible()
155
+ self._update_highlight()
156
+
157
+ def _update_highlight(self):
158
+ txt = self.edit.text()
159
+ if not txt:
160
+ self._clear_highlight()
161
+ return
162
+
163
+ doc = self.editor.document()
164
+ flags = self._flags(False)
165
+ cur = QTextCursor(doc)
166
+ cur.movePosition(QTextCursor.Start)
167
+
168
+ fmt = QTextCharFormat()
169
+ hl = self.palette().highlight().color()
170
+ hl.setAlpha(90)
171
+ fmt.setBackground(hl)
172
+
173
+ selections = []
174
+ while True:
175
+ cur = doc.find(txt, cur, flags)
176
+ if cur.isNull():
177
+ break
178
+ sel = QTextEdit.ExtraSelection()
179
+ sel.cursor = cur
180
+ sel.format = fmt
181
+ selections.append(sel)
182
+
183
+ self.editor.setExtraSelections(selections)
184
+
185
+ def _clear_highlight(self):
186
+ self.editor.setExtraSelections([])
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import difflib, re, html as _html
4
+ from datetime import datetime
4
5
  from PySide6.QtCore import Qt, Slot
5
6
  from PySide6.QtWidgets import (
6
7
  QDialog,
@@ -15,29 +16,33 @@ from PySide6.QtWidgets import (
15
16
  )
16
17
 
17
18
 
18
- def _html_to_text(s: str) -> str:
19
- """Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
20
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
21
- COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
22
- BR_RE = re.compile(r"(?i)<br\s*/?>")
23
- BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
24
- TAG_RE = re.compile(r"<[^>]+>")
25
- MULTINL_RE = re.compile(r"\n{3,}")
26
-
27
- s = STYLE_SCRIPT_RE.sub("", s)
28
- s = COMMENT_RE.sub("", s)
29
- s = BR_RE.sub("\n", s)
30
- s = BLOCK_END_RE.sub("\n", s)
31
- s = TAG_RE.sub("", s)
32
- s = _html.unescape(s)
33
- s = MULTINL_RE.sub("\n\n", s)
19
+ def _markdown_to_text(s: str) -> str:
20
+ """Convert markdown to plain text for diff comparison."""
21
+ # Remove images
22
+ s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
23
+ # Remove inline code formatting
24
+ s = re.sub(r"`([^`]+)`", r"\1", s)
25
+ # Remove bold/italic markers
26
+ s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
27
+ s = re.sub(r"__([^_]+)__", r"\1", s)
28
+ s = re.sub(r"\*([^*]+)\*", r"\1", s)
29
+ s = re.sub(r"_([^_]+)_", r"\1", s)
30
+ # Remove strikethrough
31
+ s = re.sub(r"~~([^~]+)~~", r"\1", s)
32
+ # Remove heading markers
33
+ s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
34
+ # Remove list markers
35
+ s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
36
+ s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
37
+ # Remove checkbox markers
38
+ s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
34
39
  return s.strip()
35
40
 
36
41
 
37
- def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
42
+ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
38
43
  """Return HTML with colored unified diff (+ green, - red, context gray)."""
39
- a = _html_to_text(old_html).splitlines()
40
- b = _html_to_text(new_html).splitlines()
44
+ a = _markdown_to_text(old_md).splitlines()
45
+ b = _markdown_to_text(new_md).splitlines()
41
46
  ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
42
47
  lines = []
43
48
  for line in ud:
@@ -104,6 +109,14 @@ class HistoryDialog(QDialog):
104
109
  self._load_versions()
105
110
 
106
111
  # --- Data/UX helpers ---
112
+ def _fmt_local(self, iso_utc: str) -> str:
113
+ """
114
+ Convert UTC in the database to user's local tz
115
+ """
116
+ dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
117
+ local = dt.astimezone()
118
+ return local.strftime("%Y-%m-%d %H:%M:%S %Z")
119
+
107
120
  def _load_versions(self):
108
121
  self._versions = self._db.list_versions(
109
122
  self._date
@@ -113,7 +126,7 @@ class HistoryDialog(QDialog):
113
126
  )
114
127
  self.list.clear()
115
128
  for v in self._versions:
116
- label = f"v{v['version_no']} — {v['created_at']}"
129
+ label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}"
117
130
  if v.get("note"):
118
131
  label += f" · {v['note']}"
119
132
  if v["is_current"]:
@@ -139,13 +152,17 @@ class HistoryDialog(QDialog):
139
152
  self.btn_revert.setEnabled(False)
140
153
  return
141
154
  sel_id = item.data(Qt.UserRole)
142
- # Preview selected as HTML
155
+ # Preview selected as plain text (markdown)
143
156
  sel = self._db.get_version(version_id=sel_id)
144
- self.preview.setHtml(sel["content"])
157
+ # Show markdown as plain text with monospace font for better readability
158
+ self.preview.setPlainText(sel["content"])
159
+ self.preview.setStyleSheet(
160
+ "font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
161
+ )
145
162
  # Diff vs current (textual diff)
146
163
  cur = self._db.get_version(version_id=self._current_id)
147
164
  self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
148
- # Enable revert only if selecting a non-current
165
+ # Enable revert only if selecting a non-current version
149
166
  self.btn_revert.setEnabled(sel_id != self._current_id)
150
167
 
151
168
  @Slot()
@@ -156,24 +173,10 @@ class HistoryDialog(QDialog):
156
173
  sel_id = item.data(Qt.UserRole)
157
174
  if sel_id == self._current_id:
158
175
  return
159
- sel = self._db.get_version(version_id=sel_id)
160
- vno = sel["version_no"]
161
- # Confirm
162
- if (
163
- QMessageBox.question(
164
- self,
165
- "Revert",
166
- f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
167
- QMessageBox.Yes | QMessageBox.No,
168
- )
169
- != QMessageBox.Yes
170
- ):
171
- return
172
- # Flip head pointer
176
+ # Flip head pointer to the older version
173
177
  try:
174
178
  self._db.revert_to_version(self._date, version_id=sel_id)
175
179
  except Exception as e:
176
180
  QMessageBox.critical(self, "Revert failed", str(e))
177
181
  return
178
- QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
179
- self.accept() # let the caller refresh the editor
182
+ self.accept()