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.
- {bouquin-0.1.7 → bouquin-0.2.0.1}/PKG-INFO +16 -10
- {bouquin-0.1.7 → bouquin-0.2.0.1}/README.md +15 -9
- bouquin-0.2.0.1/bouquin/__init__.py +0 -0
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/db.py +82 -67
- bouquin-0.2.0.1/bouquin/find_bar.py +186 -0
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/history_dialog.py +42 -39
- {bouquin-0.1.7 → 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.7 → bouquin-0.2.0.1}/bouquin/main_window.py +479 -100
- bouquin-0.2.0.1/bouquin/markdown_editor.py +780 -0
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/save_dialog.py +3 -0
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/search.py +65 -36
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/settings.py +9 -3
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/settings_dialog.py +114 -20
- bouquin-0.2.0.1/bouquin/theme.py +104 -0
- {bouquin-0.1.7 → bouquin-0.2.0.1}/bouquin/toolbar.py +71 -44
- {bouquin-0.1.7 → bouquin-0.2.0.1}/pyproject.toml +1 -1
- bouquin-0.1.7/bouquin/__init__.py +0 -1
- bouquin-0.1.7/bouquin/editor.py +0 -248
- bouquin-0.1.7/bouquin/main.py +0 -16
- {bouquin-0.1.7 → bouquin-0.2.0.1}/LICENSE +0 -0
- {bouquin-0.1.7 → 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,25 +37,36 @@ 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
|
|
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 `
|
|
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
|

|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
@@ -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 = "
|
|
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
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
s =
|
|
28
|
-
s =
|
|
29
|
-
|
|
30
|
-
s =
|
|
31
|
-
|
|
32
|
-
s =
|
|
33
|
-
|
|
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(
|
|
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 =
|
|
40
|
-
b =
|
|
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
|
|
155
|
+
# Preview selected as plain text (markdown)
|
|
143
156
|
sel = self._db.get_version(version_id=sel_id)
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
self.accept() # let the caller refresh the editor
|
|
182
|
+
self.accept()
|