bouquin 0.1.9__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.
- {bouquin-0.1.9 → bouquin-0.2.0.1}/PKG-INFO +12 -5
- {bouquin-0.1.9 → bouquin-0.2.0.1}/README.md +11 -4
- bouquin-0.2.0.1/bouquin/__init__.py +0 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/db.py +51 -65
- bouquin-0.2.0.1/bouquin/find_bar.py +186 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/history_dialog.py +32 -26
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/key_prompt.py +6 -0
- bouquin-0.2.0.1/bouquin/lock_overlay.py +133 -0
- bouquin-0.2.0.1/bouquin/main.py +24 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/main_window.py +343 -105
- bouquin-0.2.0.1/bouquin/markdown_editor.py +780 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/save_dialog.py +3 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/search.py +65 -36
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/settings.py +9 -3
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/settings_dialog.py +54 -3
- bouquin-0.2.0.1/bouquin/theme.py +104 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/toolbar.py +9 -41
- {bouquin-0.1.9 → bouquin-0.2.0.1}/pyproject.toml +1 -1
- bouquin-0.1.9/bouquin/__init__.py +0 -1
- bouquin-0.1.9/bouquin/editor.py +0 -638
- bouquin-0.1.9/bouquin/main.py +0 -16
- {bouquin-0.1.9 → bouquin-0.2.0.1}/LICENSE +0 -0
- {bouquin-0.1.9 → bouquin-0.2.0.1}/bouquin/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.1
|
|
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,19 +37,26 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
37
37
|
|
|
38
38
|

|
|
39
39
|
|
|
40
|
+

|
|
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
|
|
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
|
|
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
|
|
@@ -76,5 +83,5 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
|
|
|
76
83
|
* Clone the repo
|
|
77
84
|
* Ensure you have poetry installed
|
|
78
85
|
* Run `poetry install --with test`
|
|
79
|
-
* Run `
|
|
86
|
+
* Run `./tests.sh`
|
|
80
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,19 +17,26 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
20
|
+

|
|
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
|
|
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
|
|
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
|
|
@@ -56,4 +63,4 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
|
|
|
56
63
|
* Clone the repo
|
|
57
64
|
* Ensure you have poetry installed
|
|
58
65
|
* Run `poetry install --with test`
|
|
59
|
-
* Run `
|
|
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:
|
|
@@ -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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
|
@@ -411,6 +371,9 @@ class DBManager:
|
|
|
411
371
|
def export_html(
|
|
412
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,6 +393,21 @@ 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
|
+
|
|
433
411
|
def export_sql(self, file_path: str) -> None:
|
|
434
412
|
"""
|
|
435
413
|
Exports the encrypted database as plaintext SQL.
|
|
@@ -450,6 +428,10 @@ class DBManager:
|
|
|
450
428
|
cur.execute("DETACH DATABASE backup")
|
|
451
429
|
|
|
452
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
|
+
"""
|
|
453
435
|
entries = self.get_all_entries()
|
|
454
436
|
ext = os.path.splitext(file_path)[1].lower()
|
|
455
437
|
|
|
@@ -461,6 +443,10 @@ class DBManager:
|
|
|
461
443
|
self.export_txt(entries, file_path)
|
|
462
444
|
elif ext in {".html", ".htm"}:
|
|
463
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)
|
|
464
450
|
else:
|
|
465
451
|
raise ValueError(f"Unsupported extension: {ext}")
|
|
466
452
|
|
|
@@ -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([])
|
|
@@ -16,29 +16,33 @@ from PySide6.QtWidgets import (
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
s =
|
|
29
|
-
s =
|
|
30
|
-
|
|
31
|
-
s =
|
|
32
|
-
|
|
33
|
-
s =
|
|
34
|
-
|
|
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)
|
|
35
39
|
return s.strip()
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
def _colored_unified_diff_html(
|
|
42
|
+
def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
|
39
43
|
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
|
40
|
-
a =
|
|
41
|
-
b =
|
|
44
|
+
a = _markdown_to_text(old_md).splitlines()
|
|
45
|
+
b = _markdown_to_text(new_md).splitlines()
|
|
42
46
|
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
|
43
47
|
lines = []
|
|
44
48
|
for line in ud:
|
|
@@ -148,13 +152,17 @@ class HistoryDialog(QDialog):
|
|
|
148
152
|
self.btn_revert.setEnabled(False)
|
|
149
153
|
return
|
|
150
154
|
sel_id = item.data(Qt.UserRole)
|
|
151
|
-
# Preview selected as
|
|
155
|
+
# Preview selected as plain text (markdown)
|
|
152
156
|
sel = self._db.get_version(version_id=sel_id)
|
|
153
|
-
|
|
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
|
+
)
|
|
154
162
|
# Diff vs current (textual diff)
|
|
155
163
|
cur = self._db.get_version(version_id=self._current_id)
|
|
156
164
|
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
|
157
|
-
# Enable revert only if selecting a non-current
|
|
165
|
+
# Enable revert only if selecting a non-current version
|
|
158
166
|
self.btn_revert.setEnabled(sel_id != self._current_id)
|
|
159
167
|
|
|
160
168
|
@Slot()
|
|
@@ -165,12 +173,10 @@ class HistoryDialog(QDialog):
|
|
|
165
173
|
sel_id = item.data(Qt.UserRole)
|
|
166
174
|
if sel_id == self._current_id:
|
|
167
175
|
return
|
|
168
|
-
|
|
169
|
-
vno = sel["version_no"]
|
|
170
|
-
# Flip head pointer
|
|
176
|
+
# Flip head pointer to the older version
|
|
171
177
|
try:
|
|
172
178
|
self._db.revert_to_version(self._date, version_id=sel_id)
|
|
173
179
|
except Exception as e:
|
|
174
180
|
QMessageBox.critical(self, "Revert failed", str(e))
|
|
175
181
|
return
|
|
176
|
-
self.accept()
|
|
182
|
+
self.accept()
|
|
@@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
|
|
|
17
17
|
title: str = "Enter key",
|
|
18
18
|
message: str = "Enter key",
|
|
19
19
|
):
|
|
20
|
+
"""
|
|
21
|
+
Prompt the user for the key required to decrypt the database.
|
|
22
|
+
|
|
23
|
+
Used when opening the app, unlocking the idle locked screen,
|
|
24
|
+
or when rekeying.
|
|
25
|
+
"""
|
|
20
26
|
super().__init__(parent)
|
|
21
27
|
self.setWindowTitle(title)
|
|
22
28
|
v = QVBoxLayout(self)
|