bouquin 0.1.3__tar.gz → 0.1.5__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.
Potentially problematic release.
This version of bouquin might be problematic. Click here for more details.
- {bouquin-0.1.3 → bouquin-0.1.5}/PKG-INFO +2 -1
- {bouquin-0.1.3 → bouquin-0.1.5}/README.md +1 -0
- bouquin-0.1.5/bouquin/db.py +451 -0
- bouquin-0.1.5/bouquin/history_dialog.py +179 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/main_window.py +230 -34
- bouquin-0.1.5/bouquin/save_dialog.py +35 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/settings.py +3 -1
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/settings_dialog.py +47 -14
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/toolbar.py +9 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/pyproject.toml +1 -1
- bouquin-0.1.3/bouquin/db.py +0 -226
- {bouquin-0.1.3 → bouquin-0.1.5}/LICENSE +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/__init__.py +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/__main__.py +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/editor.py +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/key_prompt.py +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/main.py +0 -0
- {bouquin-0.1.3 → bouquin-0.1.5}/bouquin/search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
@@ -46,6 +46,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
46
46
|
* Search
|
|
47
47
|
* Automatic periodic saving (or explicitly save)
|
|
48
48
|
* Transparent integrity checking of the database when it opens
|
|
49
|
+
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
49
50
|
* Rekey the database (change the password)
|
|
50
51
|
* Export the database to json, txt, html or csv
|
|
51
52
|
|
|
@@ -26,6 +26,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
26
26
|
* Search
|
|
27
27
|
* Automatic periodic saving (or explicitly save)
|
|
28
28
|
* Transparent integrity checking of the database when it opens
|
|
29
|
+
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
29
30
|
* Rekey the database (change the password)
|
|
30
31
|
* Export the database to json, txt, html or csv
|
|
31
32
|
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from sqlcipher3 import dbapi2 as sqlite
|
|
11
|
+
from typing import List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
Entry = Tuple[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DBConfig:
|
|
18
|
+
path: Path
|
|
19
|
+
key: str
|
|
20
|
+
idle_minutes: int = 15 # 0 = never lock
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DBManager:
|
|
24
|
+
def __init__(self, cfg: DBConfig):
|
|
25
|
+
self.cfg = cfg
|
|
26
|
+
self.conn: sqlite.Connection | None = None
|
|
27
|
+
|
|
28
|
+
def connect(self) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Open, decrypt and install schema on the database.
|
|
31
|
+
"""
|
|
32
|
+
# Ensure parent dir exists
|
|
33
|
+
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self.conn = sqlite.connect(str(self.cfg.path))
|
|
35
|
+
self.conn.row_factory = sqlite.Row
|
|
36
|
+
cur = self.conn.cursor()
|
|
37
|
+
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
|
38
|
+
cur.execute("PRAGMA foreign_keys = ON;")
|
|
39
|
+
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
|
40
|
+
try:
|
|
41
|
+
self._integrity_ok()
|
|
42
|
+
except Exception:
|
|
43
|
+
self.conn.close()
|
|
44
|
+
self.conn = None
|
|
45
|
+
return False
|
|
46
|
+
self._ensure_schema()
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _integrity_ok(self) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Runs the cipher_integrity_check PRAGMA on the database.
|
|
52
|
+
"""
|
|
53
|
+
cur = self.conn.cursor()
|
|
54
|
+
cur.execute("PRAGMA cipher_integrity_check;")
|
|
55
|
+
rows = cur.fetchall()
|
|
56
|
+
|
|
57
|
+
# OK: nothing returned
|
|
58
|
+
if not rows:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Not OK: rows of problems returned
|
|
62
|
+
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
|
63
|
+
raise sqlite.IntegrityError(
|
|
64
|
+
"SQLCipher integrity check failed"
|
|
65
|
+
+ (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _ensure_schema(self) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Install the expected schema on the database.
|
|
71
|
+
We also handle upgrades here.
|
|
72
|
+
"""
|
|
73
|
+
cur = self.conn.cursor()
|
|
74
|
+
# Always keep FKs on
|
|
75
|
+
cur.execute("PRAGMA foreign_keys = ON;")
|
|
76
|
+
|
|
77
|
+
# Create new versioned schema if missing (< 0.1.5)
|
|
78
|
+
cur.executescript(
|
|
79
|
+
"""
|
|
80
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
81
|
+
date TEXT PRIMARY KEY, -- yyyy-MM-dd
|
|
82
|
+
current_version_id INTEGER,
|
|
83
|
+
FOREIGN KEY(current_version_id) REFERENCES versions(id) ON DELETE SET NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS versions (
|
|
87
|
+
id INTEGER PRIMARY KEY,
|
|
88
|
+
date TEXT NOT NULL, -- FK to pages.date
|
|
89
|
+
version_no INTEGER NOT NULL, -- 1,2,3… per date
|
|
90
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
91
|
+
note TEXT,
|
|
92
|
+
content TEXT NOT NULL,
|
|
93
|
+
FOREIGN KEY(date) REFERENCES pages(date) ON DELETE CASCADE
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
|
|
98
|
+
"""
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
|
|
102
|
+
pre_0_1_5 = cur.execute(
|
|
103
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
|
|
104
|
+
).fetchone()
|
|
105
|
+
pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
|
|
106
|
+
|
|
107
|
+
if pre_0_1_5 and pages_empty:
|
|
108
|
+
# Seed pages and versions (all as version 1)
|
|
109
|
+
cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
|
|
110
|
+
cur.execute(
|
|
111
|
+
"INSERT INTO versions(date, version_no, content) "
|
|
112
|
+
"SELECT date, 1, content FROM entries;"
|
|
113
|
+
)
|
|
114
|
+
# Point head to v1 for each page
|
|
115
|
+
cur.execute(
|
|
116
|
+
"""
|
|
117
|
+
UPDATE pages
|
|
118
|
+
SET current_version_id = (
|
|
119
|
+
SELECT v.id FROM versions v
|
|
120
|
+
WHERE v.date = pages.date AND v.version_no = 1
|
|
121
|
+
);
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
cur.execute("DROP TABLE IF EXISTS entries;")
|
|
125
|
+
self.conn.commit()
|
|
126
|
+
|
|
127
|
+
def rekey(self, new_key: str) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Change the SQLCipher passphrase in-place, then reopen the connection
|
|
130
|
+
with the new key to verify.
|
|
131
|
+
"""
|
|
132
|
+
if self.conn is None:
|
|
133
|
+
raise RuntimeError("Database is not connected")
|
|
134
|
+
cur = self.conn.cursor()
|
|
135
|
+
# Change the encryption key of the currently open database
|
|
136
|
+
cur.execute(f"PRAGMA rekey = '{new_key}';")
|
|
137
|
+
self.conn.commit()
|
|
138
|
+
|
|
139
|
+
# Close and reopen with the new key to verify and restore PRAGMAs
|
|
140
|
+
self.conn.close()
|
|
141
|
+
self.conn = None
|
|
142
|
+
self.cfg.key = new_key
|
|
143
|
+
if not self.connect():
|
|
144
|
+
raise sqlite.Error("Re-open failed after rekey")
|
|
145
|
+
|
|
146
|
+
def get_entry(self, date_iso: str) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Get a single entry by its date.
|
|
149
|
+
"""
|
|
150
|
+
cur = self.conn.cursor()
|
|
151
|
+
row = cur.execute(
|
|
152
|
+
"""
|
|
153
|
+
SELECT v.content
|
|
154
|
+
FROM pages p
|
|
155
|
+
JOIN versions v ON v.id = p.current_version_id
|
|
156
|
+
WHERE p.date = ?;
|
|
157
|
+
""",
|
|
158
|
+
(date_iso,),
|
|
159
|
+
).fetchone()
|
|
160
|
+
return row[0] if row else ""
|
|
161
|
+
|
|
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
|
+
def search_entries(self, text: str) -> list[str]:
|
|
170
|
+
"""
|
|
171
|
+
Search for entries by term. This only works against the latest
|
|
172
|
+
version of the page.
|
|
173
|
+
"""
|
|
174
|
+
cur = self.conn.cursor()
|
|
175
|
+
pattern = f"%{text}%"
|
|
176
|
+
rows = cur.execute(
|
|
177
|
+
"""
|
|
178
|
+
SELECT p.date, v.content
|
|
179
|
+
FROM pages AS p
|
|
180
|
+
JOIN versions AS v
|
|
181
|
+
ON v.id = p.current_version_id
|
|
182
|
+
WHERE TRIM(v.content) <> ''
|
|
183
|
+
AND v.content LIKE LOWER(?) ESCAPE '\\'
|
|
184
|
+
ORDER BY p.date DESC;
|
|
185
|
+
""",
|
|
186
|
+
(pattern,),
|
|
187
|
+
).fetchall()
|
|
188
|
+
return [(r[0], r[1]) for r in rows]
|
|
189
|
+
|
|
190
|
+
def dates_with_content(self) -> list[str]:
|
|
191
|
+
"""
|
|
192
|
+
Find all entries and return the dates of them.
|
|
193
|
+
This is used to mark the calendar days in bold if they contain entries.
|
|
194
|
+
"""
|
|
195
|
+
cur = self.conn.cursor()
|
|
196
|
+
rows = cur.execute(
|
|
197
|
+
"""
|
|
198
|
+
SELECT p.date
|
|
199
|
+
FROM pages p
|
|
200
|
+
JOIN versions v ON v.id = p.current_version_id
|
|
201
|
+
WHERE TRIM(v.content) <> ''
|
|
202
|
+
ORDER BY p.date;
|
|
203
|
+
"""
|
|
204
|
+
).fetchall()
|
|
205
|
+
return [r[0] for r in rows]
|
|
206
|
+
|
|
207
|
+
# ------------------------- Versioning logic here ------------------------#
|
|
208
|
+
def save_new_version(
|
|
209
|
+
self,
|
|
210
|
+
date_iso: str,
|
|
211
|
+
content: str,
|
|
212
|
+
note: str | None = None,
|
|
213
|
+
set_current: bool = True,
|
|
214
|
+
) -> tuple[int, int]:
|
|
215
|
+
"""
|
|
216
|
+
Append a new version for this date. Returns (version_id, version_no).
|
|
217
|
+
If set_current=True, flips the page head to this new version.
|
|
218
|
+
"""
|
|
219
|
+
if self.conn is None:
|
|
220
|
+
raise RuntimeError("Database is not connected")
|
|
221
|
+
with self.conn: # transaction
|
|
222
|
+
cur = self.conn.cursor()
|
|
223
|
+
# Ensure page row exists
|
|
224
|
+
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
|
|
225
|
+
# Next version number
|
|
226
|
+
row = cur.execute(
|
|
227
|
+
"SELECT COALESCE(MAX(version_no), 0) AS maxv FROM versions WHERE date=?;",
|
|
228
|
+
(date_iso,),
|
|
229
|
+
).fetchone()
|
|
230
|
+
next_ver = int(row["maxv"]) + 1
|
|
231
|
+
# Insert the version
|
|
232
|
+
cur.execute(
|
|
233
|
+
"INSERT INTO versions(date, version_no, content, note) "
|
|
234
|
+
"VALUES (?,?,?,?);",
|
|
235
|
+
(date_iso, next_ver, content, note),
|
|
236
|
+
)
|
|
237
|
+
ver_id = cur.lastrowid
|
|
238
|
+
if set_current:
|
|
239
|
+
cur.execute(
|
|
240
|
+
"UPDATE pages SET current_version_id=? WHERE date=?;",
|
|
241
|
+
(ver_id, date_iso),
|
|
242
|
+
)
|
|
243
|
+
return ver_id, next_ver
|
|
244
|
+
|
|
245
|
+
def list_versions(self, date_iso: str) -> list[dict]:
|
|
246
|
+
"""
|
|
247
|
+
Returns history for a given date (newest first), including which one is current.
|
|
248
|
+
Each item: {id, version_no, created_at, note, is_current}
|
|
249
|
+
"""
|
|
250
|
+
cur = self.conn.cursor()
|
|
251
|
+
rows = cur.execute(
|
|
252
|
+
"""
|
|
253
|
+
SELECT v.id, v.version_no, v.created_at, v.note,
|
|
254
|
+
CASE WHEN v.id = p.current_version_id THEN 1 ELSE 0 END AS is_current
|
|
255
|
+
FROM versions v
|
|
256
|
+
LEFT JOIN pages p ON p.date = v.date
|
|
257
|
+
WHERE v.date = ?
|
|
258
|
+
ORDER BY v.version_no DESC;
|
|
259
|
+
""",
|
|
260
|
+
(date_iso,),
|
|
261
|
+
).fetchall()
|
|
262
|
+
return [dict(r) for r in rows]
|
|
263
|
+
|
|
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:
|
|
271
|
+
"""
|
|
272
|
+
Fetch a specific version by (date, version_no) OR by version_id.
|
|
273
|
+
Returns a dict with keys: id, date, version_no, created_at, note, content.
|
|
274
|
+
"""
|
|
275
|
+
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()
|
|
292
|
+
return dict(row) if row else None
|
|
293
|
+
|
|
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:
|
|
301
|
+
"""
|
|
302
|
+
Point the page head (pages.current_version_id) to an existing version.
|
|
303
|
+
Fast revert: no content is rewritten.
|
|
304
|
+
"""
|
|
305
|
+
if self.conn is None:
|
|
306
|
+
raise RuntimeError("Database is not connected")
|
|
307
|
+
cur = self.conn.cursor()
|
|
308
|
+
|
|
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")
|
|
326
|
+
|
|
327
|
+
with self.conn:
|
|
328
|
+
cur.execute(
|
|
329
|
+
"UPDATE pages SET current_version_id=? WHERE date=?;",
|
|
330
|
+
(version_id, date_iso),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# ------------------------- Export logic here ------------------------#
|
|
334
|
+
def get_all_entries(self) -> List[Entry]:
|
|
335
|
+
"""
|
|
336
|
+
Get all entries. Used for exports.
|
|
337
|
+
"""
|
|
338
|
+
cur = self.conn.cursor()
|
|
339
|
+
rows = cur.execute(
|
|
340
|
+
"""
|
|
341
|
+
SELECT p.date, v.content
|
|
342
|
+
FROM pages p
|
|
343
|
+
JOIN versions v ON v.id = p.current_version_id
|
|
344
|
+
ORDER BY p.date;
|
|
345
|
+
"""
|
|
346
|
+
).fetchall()
|
|
347
|
+
return [(r[0], r[1]) for r in rows]
|
|
348
|
+
|
|
349
|
+
def export_json(
|
|
350
|
+
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
|
351
|
+
) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Export to json.
|
|
354
|
+
"""
|
|
355
|
+
data = [{"date": d, "content": c} for d, c in entries]
|
|
356
|
+
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=(",", ":"))
|
|
361
|
+
|
|
362
|
+
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
|
363
|
+
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
|
364
|
+
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
|
365
|
+
writer = csv.writer(f)
|
|
366
|
+
writer.writerow(["date", "content"]) # header
|
|
367
|
+
writer.writerows(entries)
|
|
368
|
+
|
|
369
|
+
def export_txt(
|
|
370
|
+
self,
|
|
371
|
+
entries: Sequence[Entry],
|
|
372
|
+
file_path: str,
|
|
373
|
+
separator: str = "\n\n— — — — —\n\n",
|
|
374
|
+
strip_html: bool = True,
|
|
375
|
+
) -> None:
|
|
376
|
+
import re, html as _html
|
|
377
|
+
|
|
378
|
+
# Precompiled patterns
|
|
379
|
+
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
|
380
|
+
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
381
|
+
BR_RE = re.compile(r"(?i)<br\\s*/?>")
|
|
382
|
+
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
|
|
383
|
+
TAG_RE = re.compile(r"<[^>]+>")
|
|
384
|
+
WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
|
|
385
|
+
MULTINEWLINE_RE = re.compile(r"\\n{3,}")
|
|
386
|
+
|
|
387
|
+
def _strip(s: str) -> str:
|
|
388
|
+
# 1) Remove <style> and <script> blocks *including their contents*
|
|
389
|
+
s = STYLE_SCRIPT_RE.sub("", s)
|
|
390
|
+
# 2) Remove HTML comments
|
|
391
|
+
s = COMMENT_RE.sub("", s)
|
|
392
|
+
# 3) Turn some block-ish boundaries into newlines before removing tags
|
|
393
|
+
s = BR_RE.sub("\n", s)
|
|
394
|
+
s = BLOCK_END_RE.sub("\n", s)
|
|
395
|
+
# 4) Drop remaining tags
|
|
396
|
+
s = TAG_RE.sub("", s)
|
|
397
|
+
# 5) Unescape entities ( etc.)
|
|
398
|
+
s = _html.unescape(s)
|
|
399
|
+
# 6) Tidy whitespace
|
|
400
|
+
s = WS_ENDS_RE.sub("\n", s)
|
|
401
|
+
s = MULTINEWLINE_RE.sub("\n\n", s)
|
|
402
|
+
return s.strip()
|
|
403
|
+
|
|
404
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
405
|
+
for i, (d, c) in enumerate(entries):
|
|
406
|
+
body = _strip(c) if strip_html else c
|
|
407
|
+
f.write(f"{d}\n{body}\n")
|
|
408
|
+
if i < len(entries) - 1:
|
|
409
|
+
f.write(separator)
|
|
410
|
+
|
|
411
|
+
def export_html(
|
|
412
|
+
self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
|
|
413
|
+
) -> None:
|
|
414
|
+
parts = [
|
|
415
|
+
"<!doctype html>",
|
|
416
|
+
'<html lang="en">',
|
|
417
|
+
'<meta charset="utf-8">',
|
|
418
|
+
f"<title>{html.escape(title)}</title>",
|
|
419
|
+
"<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
|
|
420
|
+
"article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
|
|
421
|
+
"<body>",
|
|
422
|
+
f"<h1>{html.escape(title)}</h1>",
|
|
423
|
+
]
|
|
424
|
+
for d, c in entries:
|
|
425
|
+
parts.append(
|
|
426
|
+
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
|
|
427
|
+
)
|
|
428
|
+
parts.append("</body></html>")
|
|
429
|
+
|
|
430
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
431
|
+
f.write("\n".join(parts))
|
|
432
|
+
|
|
433
|
+
def export_by_extension(self, file_path: str) -> None:
|
|
434
|
+
entries = self.get_all_entries()
|
|
435
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
436
|
+
|
|
437
|
+
if ext == ".json":
|
|
438
|
+
self.export_json(entries, file_path)
|
|
439
|
+
elif ext == ".csv":
|
|
440
|
+
self.export_csv(entries, file_path)
|
|
441
|
+
elif ext == ".txt":
|
|
442
|
+
self.export_txt(entries, file_path)
|
|
443
|
+
elif ext in {".html", ".htm"}:
|
|
444
|
+
self.export_html(entries, file_path)
|
|
445
|
+
else:
|
|
446
|
+
raise ValueError(f"Unsupported extension: {ext}")
|
|
447
|
+
|
|
448
|
+
def close(self) -> None:
|
|
449
|
+
if self.conn is not None:
|
|
450
|
+
self.conn.close()
|
|
451
|
+
self.conn = None
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib, re, html as _html
|
|
4
|
+
from PySide6.QtCore import Qt, Slot
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QDialog,
|
|
7
|
+
QVBoxLayout,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QListWidget,
|
|
10
|
+
QListWidgetItem,
|
|
11
|
+
QPushButton,
|
|
12
|
+
QMessageBox,
|
|
13
|
+
QTextBrowser,
|
|
14
|
+
QTabWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
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)
|
|
34
|
+
return s.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
|
|
38
|
+
"""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()
|
|
41
|
+
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
|
42
|
+
lines = []
|
|
43
|
+
for line in ud:
|
|
44
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
45
|
+
lines.append(
|
|
46
|
+
f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
|
|
47
|
+
)
|
|
48
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
49
|
+
lines.append(
|
|
50
|
+
f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
|
|
51
|
+
)
|
|
52
|
+
elif line.startswith("@@"):
|
|
53
|
+
lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
|
|
54
|
+
else:
|
|
55
|
+
lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
|
|
56
|
+
css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
|
|
57
|
+
return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HistoryDialog(QDialog):
|
|
61
|
+
"""Show versions for a date, preview, diff, and allow revert."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, db, date_iso: str, parent=None):
|
|
64
|
+
super().__init__(parent)
|
|
65
|
+
self.setWindowTitle(f"History — {date_iso}")
|
|
66
|
+
self._db = db
|
|
67
|
+
self._date = date_iso
|
|
68
|
+
self._versions = [] # list[dict] from DB
|
|
69
|
+
self._current_id = None # id of current
|
|
70
|
+
|
|
71
|
+
root = QVBoxLayout(self)
|
|
72
|
+
|
|
73
|
+
# Top: list of versions
|
|
74
|
+
top = QHBoxLayout()
|
|
75
|
+
self.list = QListWidget()
|
|
76
|
+
self.list.setMinimumSize(500, 650)
|
|
77
|
+
self.list.currentItemChanged.connect(self._on_select)
|
|
78
|
+
top.addWidget(self.list, 1)
|
|
79
|
+
|
|
80
|
+
# Right: tabs (Preview / Diff vs current)
|
|
81
|
+
self.tabs = QTabWidget()
|
|
82
|
+
self.preview = QTextBrowser()
|
|
83
|
+
self.preview.setOpenExternalLinks(True)
|
|
84
|
+
self.diff = QTextBrowser()
|
|
85
|
+
self.diff.setOpenExternalLinks(False)
|
|
86
|
+
self.tabs.addTab(self.preview, "Preview")
|
|
87
|
+
self.tabs.addTab(self.diff, "Diff vs current")
|
|
88
|
+
self.tabs.setMinimumSize(500, 650)
|
|
89
|
+
top.addWidget(self.tabs, 2)
|
|
90
|
+
|
|
91
|
+
root.addLayout(top)
|
|
92
|
+
|
|
93
|
+
# Buttons
|
|
94
|
+
row = QHBoxLayout()
|
|
95
|
+
row.addStretch(1)
|
|
96
|
+
self.btn_revert = QPushButton("Revert to Selected")
|
|
97
|
+
self.btn_revert.clicked.connect(self._revert)
|
|
98
|
+
self.btn_close = QPushButton("Close")
|
|
99
|
+
self.btn_close.clicked.connect(self.reject)
|
|
100
|
+
row.addWidget(self.btn_revert)
|
|
101
|
+
row.addWidget(self.btn_close)
|
|
102
|
+
root.addLayout(row)
|
|
103
|
+
|
|
104
|
+
self._load_versions()
|
|
105
|
+
|
|
106
|
+
# --- Data/UX helpers ---
|
|
107
|
+
def _load_versions(self):
|
|
108
|
+
self._versions = self._db.list_versions(
|
|
109
|
+
self._date
|
|
110
|
+
) # [{id,version_no,created_at,note,is_current}]
|
|
111
|
+
self._current_id = next(
|
|
112
|
+
(v["id"] for v in self._versions if v["is_current"]), None
|
|
113
|
+
)
|
|
114
|
+
self.list.clear()
|
|
115
|
+
for v in self._versions:
|
|
116
|
+
label = f"v{v['version_no']} — {v['created_at']}"
|
|
117
|
+
if v.get("note"):
|
|
118
|
+
label += f" · {v['note']}"
|
|
119
|
+
if v["is_current"]:
|
|
120
|
+
label += " **(current)**"
|
|
121
|
+
it = QListWidgetItem(label)
|
|
122
|
+
it.setData(Qt.UserRole, v["id"])
|
|
123
|
+
self.list.addItem(it)
|
|
124
|
+
# select the first non-current if available, else current
|
|
125
|
+
idx = 0
|
|
126
|
+
for i, v in enumerate(self._versions):
|
|
127
|
+
if not v["is_current"]:
|
|
128
|
+
idx = i
|
|
129
|
+
break
|
|
130
|
+
if self.list.count():
|
|
131
|
+
self.list.setCurrentRow(idx)
|
|
132
|
+
|
|
133
|
+
@Slot()
|
|
134
|
+
def _on_select(self):
|
|
135
|
+
item = self.list.currentItem()
|
|
136
|
+
if not item:
|
|
137
|
+
self.preview.clear()
|
|
138
|
+
self.diff.clear()
|
|
139
|
+
self.btn_revert.setEnabled(False)
|
|
140
|
+
return
|
|
141
|
+
sel_id = item.data(Qt.UserRole)
|
|
142
|
+
# Preview selected as HTML
|
|
143
|
+
sel = self._db.get_version(version_id=sel_id)
|
|
144
|
+
self.preview.setHtml(sel["content"])
|
|
145
|
+
# Diff vs current (textual diff)
|
|
146
|
+
cur = self._db.get_version(version_id=self._current_id)
|
|
147
|
+
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
|
148
|
+
# Enable revert only if selecting a non-current
|
|
149
|
+
self.btn_revert.setEnabled(sel_id != self._current_id)
|
|
150
|
+
|
|
151
|
+
@Slot()
|
|
152
|
+
def _revert(self):
|
|
153
|
+
item = self.list.currentItem()
|
|
154
|
+
if not item:
|
|
155
|
+
return
|
|
156
|
+
sel_id = item.data(Qt.UserRole)
|
|
157
|
+
if sel_id == self._current_id:
|
|
158
|
+
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
|
|
173
|
+
try:
|
|
174
|
+
self._db.revert_to_version(self._date, version_id=sel_id)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
QMessageBox.critical(self, "Revert failed", str(e))
|
|
177
|
+
return
|
|
178
|
+
QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
|
|
179
|
+
self.accept() # let the caller refresh the editor
|