bouquin 0.1.4__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.4
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 (&nbsp; 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
@@ -29,7 +29,9 @@ from PySide6.QtWidgets import (
29
29
 
30
30
  from .db import DBManager
31
31
  from .editor import Editor
32
+ from .history_dialog import HistoryDialog
32
33
  from .key_prompt import KeyPrompt
34
+ from .save_dialog import SaveDialog
33
35
  from .search import Search
34
36
  from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
35
37
  from .settings_dialog import SettingsDialog
@@ -146,6 +148,7 @@ class MainWindow(QMainWindow):
146
148
  self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
147
149
  self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
148
150
  self.toolBar.alignRequested.connect(self.editor.setAlignment)
151
+ self.toolBar.historyRequested.connect(self._open_history)
149
152
 
150
153
  split = QSplitter()
151
154
  split.addWidget(left_panel)
@@ -181,10 +184,15 @@ class MainWindow(QMainWindow):
181
184
  # Menu bar (File)
182
185
  mb = self.menuBar()
183
186
  file_menu = mb.addMenu("&File")
184
- act_save = QAction("&Save", self)
187
+ act_save = QAction("&Save a version", self)
185
188
  act_save.setShortcut("Ctrl+S")
186
189
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
187
190
  file_menu.addAction(act_save)
191
+ act_history = QAction("History", self)
192
+ act_history.setShortcut("Ctrl+H")
193
+ act_history.setShortcutContext(Qt.ApplicationShortcut)
194
+ act_history.triggered.connect(self._open_history)
195
+ file_menu.addAction(act_history)
188
196
  act_settings = QAction("Settin&gs", self)
189
197
  act_settings.setShortcut("Ctrl+G")
190
198
  act_settings.triggered.connect(self._open_settings)
@@ -330,7 +338,7 @@ class MainWindow(QMainWindow):
330
338
 
331
339
  def _on_text_changed(self):
332
340
  self._dirty = True
333
- self._save_timer.start(1200) # autosave after idle
341
+ self._save_timer.start(10000) # autosave after idle
334
342
 
335
343
  def _adjust_day(self, delta: int):
336
344
  """Move selection by delta days (negative for previous)."""
@@ -358,7 +366,7 @@ class MainWindow(QMainWindow):
358
366
  # Now load the newly selected date
359
367
  self._load_selected_date()
360
368
 
361
- def _save_date(self, date_iso: str, explicit: bool = False):
369
+ def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
362
370
  """
363
371
  Save editor contents into the given date. Shows status on success.
364
372
  explicit=True means user invoked Save: show feedback even if nothing changed.
@@ -367,7 +375,7 @@ class MainWindow(QMainWindow):
367
375
  return
368
376
  text = self.editor.toHtml()
369
377
  try:
370
- self.db.upsert_entry(date_iso, text)
378
+ self.db.save_new_version(date_iso, text, note)
371
379
  except Exception as e:
372
380
  QMessageBox.critical(self, "Save Error", str(e))
373
381
  return
@@ -381,9 +389,34 @@ class MainWindow(QMainWindow):
381
389
  )
382
390
 
383
391
  def _save_current(self, explicit: bool = False):
392
+ try:
393
+ self._save_timer.stop()
394
+ except Exception:
395
+ pass
396
+ if explicit:
397
+ # Prompt for a note
398
+ dlg = SaveDialog(self)
399
+ if dlg.exec() != QDialog.Accepted:
400
+ return
401
+ note = dlg.note_text()
402
+ else:
403
+ note = "autosave"
384
404
  # Delegate to _save_date for the currently selected date
385
- self._save_date(self._current_date_iso(), explicit)
405
+ self._save_date(self._current_date_iso(), explicit, note)
406
+ try:
407
+ self._save_timer.start()
408
+ except Exception:
409
+ pass
410
+
411
+ def _open_history(self):
412
+ date_iso = self._current_date_iso()
413
+ dlg = HistoryDialog(self.db, date_iso, self)
414
+ if dlg.exec() == QDialog.Accepted:
415
+ # refresh editor + calendar (head pointer may have changed)
416
+ self._load_selected_date(date_iso)
417
+ self._refresh_calendar_marks()
386
418
 
419
+ # ----------- Settings handler ------------#
387
420
  def _open_settings(self):
388
421
  dlg = SettingsDialog(self.cfg, self.db, self)
389
422
  if dlg.exec() != QDialog.Accepted:
@@ -414,6 +447,7 @@ class MainWindow(QMainWindow):
414
447
  self._load_selected_date()
415
448
  self._refresh_calendar_marks()
416
449
 
450
+ # ------------ Window positioning --------------- #
417
451
  def _restore_window_position(self):
418
452
  geom = self.settings.value("main/geometry", None)
419
453
  state = self.settings.value("main/windowState", None)
@@ -447,6 +481,7 @@ class MainWindow(QMainWindow):
447
481
  # Center the window in that screen’s available area
448
482
  self.move(r.center() - self.rect().center())
449
483
 
484
+ # ----------------- Export handler ----------------- #
450
485
  @Slot()
451
486
  def _export(self):
452
487
  try:
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ from PySide6.QtWidgets import (
6
+ QDialog,
7
+ QVBoxLayout,
8
+ QLabel,
9
+ QLineEdit,
10
+ QDialogButtonBox,
11
+ )
12
+
13
+
14
+ class SaveDialog(QDialog):
15
+ def __init__(
16
+ self,
17
+ parent=None,
18
+ title: str = "Enter a name for this version",
19
+ message: str = "Enter a name for this version?",
20
+ ):
21
+ super().__init__(parent)
22
+ self.setWindowTitle(title)
23
+ v = QVBoxLayout(self)
24
+ v.addWidget(QLabel(message))
25
+ self.note = QLineEdit()
26
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
27
+ self.note.setText(f"New version I saved at {now}")
28
+ v.addWidget(self.note)
29
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
30
+ bb.accepted.connect(self.accept)
31
+ bb.rejected.connect(self.reject)
32
+ v.addWidget(bb)
33
+
34
+ def note_text(self) -> str:
35
+ return self.note.text()
@@ -15,6 +15,7 @@ class ToolBar(QToolBar):
15
15
  bulletsRequested = Signal()
16
16
  numbersRequested = Signal()
17
17
  alignRequested = Signal(Qt.AlignmentFlag)
18
+ historyRequested = Signal()
18
19
 
19
20
  def __init__(self, parent=None):
20
21
  super().__init__("Format", parent)
@@ -76,6 +77,10 @@ class ToolBar(QToolBar):
76
77
  lambda: self.alignRequested.emit(Qt.AlignRight)
77
78
  )
78
79
 
80
+ # History button
81
+ self.actHistory = QAction("History", self)
82
+ self.actHistory.triggered.connect(self.historyRequested)
83
+
79
84
  self.addActions(
80
85
  [
81
86
  self.actBold,
@@ -92,6 +97,7 @@ class ToolBar(QToolBar):
92
97
  self.actAlignL,
93
98
  self.actAlignC,
94
99
  self.actAlignR,
100
+ self.actHistory,
95
101
  ]
96
102
  )
97
103
 
@@ -120,6 +126,9 @@ class ToolBar(QToolBar):
120
126
  self._style_letter_button(self.actAlignC, "C")
121
127
  self._style_letter_button(self.actAlignR, "R")
122
128
 
129
+ # History
130
+ self._style_letter_button(self.actHistory, "View History")
131
+
123
132
  def _style_letter_button(
124
133
  self,
125
134
  action: QAction,
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
@@ -1,227 +0,0 @@
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
- # Ensure parent dir exists
30
- self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
31
- self.conn = sqlite.connect(str(self.cfg.path))
32
- self.conn.row_factory = sqlite.Row
33
- cur = self.conn.cursor()
34
- cur.execute(f"PRAGMA key = '{self.cfg.key}';")
35
- cur.execute("PRAGMA journal_mode = WAL;")
36
- self.conn.commit()
37
- try:
38
- self._integrity_ok()
39
- except Exception:
40
- self.conn.close()
41
- self.conn = None
42
- return False
43
- self._ensure_schema()
44
- return True
45
-
46
- def _integrity_ok(self) -> bool:
47
- cur = self.conn.cursor()
48
- cur.execute("PRAGMA cipher_integrity_check;")
49
- rows = cur.fetchall()
50
-
51
- # OK
52
- if not rows:
53
- return
54
-
55
- # Not OK
56
- details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
57
- raise sqlite.IntegrityError(
58
- "SQLCipher integrity check failed"
59
- + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
60
- )
61
-
62
- def _ensure_schema(self) -> None:
63
- cur = self.conn.cursor()
64
- cur.execute(
65
- """
66
- CREATE TABLE IF NOT EXISTS entries (
67
- date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
68
- content TEXT NOT NULL
69
- );
70
- """
71
- )
72
- cur.execute("PRAGMA user_version = 1;")
73
- self.conn.commit()
74
-
75
- def rekey(self, new_key: str) -> None:
76
- """
77
- Change the SQLCipher passphrase in-place, then reopen the connection
78
- with the new key to verify.
79
- """
80
- if self.conn is None:
81
- raise RuntimeError("Database is not connected")
82
- cur = self.conn.cursor()
83
- # Change the encryption key of the currently open database
84
- cur.execute(f"PRAGMA rekey = '{new_key}';")
85
- self.conn.commit()
86
-
87
- # Close and reopen with the new key to verify and restore PRAGMAs
88
- self.conn.close()
89
- self.conn = None
90
- self.cfg.key = new_key
91
- if not self.connect():
92
- raise sqlite.Error("Re-open failed after rekey")
93
-
94
- def get_entry(self, date_iso: str) -> str:
95
- cur = self.conn.cursor()
96
- cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
97
- row = cur.fetchone()
98
- return row[0] if row else ""
99
-
100
- def upsert_entry(self, date_iso: str, content: str) -> None:
101
- cur = self.conn.cursor()
102
- cur.execute(
103
- """
104
- INSERT INTO entries(date, content) VALUES(?, ?)
105
- ON CONFLICT(date) DO UPDATE SET content = excluded.content;
106
- """,
107
- (date_iso, content),
108
- )
109
- self.conn.commit()
110
-
111
- def search_entries(self, text: str) -> list[str]:
112
- cur = self.conn.cursor()
113
- pattern = f"%{text}%"
114
- return cur.execute(
115
- "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
116
- ).fetchall()
117
-
118
- def dates_with_content(self) -> list[str]:
119
- cur = self.conn.cursor()
120
- cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
121
- return [r[0] for r in cur.fetchall()]
122
-
123
- def get_all_entries(self) -> List[Entry]:
124
- cur = self.conn.cursor()
125
- rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
126
- return [(row["date"], row["content"]) for row in rows]
127
-
128
- def export_json(
129
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
130
- ) -> None:
131
- data = [{"date": d, "content": c} for d, c in entries]
132
- with open(file_path, "w", encoding="utf-8") as f:
133
- if pretty:
134
- json.dump(data, f, ensure_ascii=False, indent=2)
135
- else:
136
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
137
-
138
- def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
139
- # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
140
- with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
141
- writer = csv.writer(f)
142
- writer.writerow(["date", "content"]) # header
143
- writer.writerows(entries)
144
-
145
- def export_txt(
146
- self,
147
- entries: Sequence[Entry],
148
- file_path: str,
149
- separator: str = "\n\n— — — — —\n\n",
150
- strip_html: bool = True,
151
- ) -> None:
152
- import re, html as _html
153
-
154
- # Precompiled patterns
155
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
156
- COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
157
- BR_RE = re.compile(r"(?i)<br\\s*/?>")
158
- BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
159
- TAG_RE = re.compile(r"<[^>]+>")
160
- WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
161
- MULTINEWLINE_RE = re.compile(r"\\n{3,}")
162
-
163
- def _strip(s: str) -> str:
164
- # 1) Remove <style> and <script> blocks *including their contents*
165
- s = STYLE_SCRIPT_RE.sub("", s)
166
- # 2) Remove HTML comments
167
- s = COMMENT_RE.sub("", s)
168
- # 3) Turn some block-ish boundaries into newlines before removing tags
169
- s = BR_RE.sub("\n", s)
170
- s = BLOCK_END_RE.sub("\n", s)
171
- # 4) Drop remaining tags
172
- s = TAG_RE.sub("", s)
173
- # 5) Unescape entities (&nbsp; etc.)
174
- s = _html.unescape(s)
175
- # 6) Tidy whitespace
176
- s = WS_ENDS_RE.sub("\n", s)
177
- s = MULTINEWLINE_RE.sub("\n\n", s)
178
- return s.strip()
179
-
180
- with open(file_path, "w", encoding="utf-8") as f:
181
- for i, (d, c) in enumerate(entries):
182
- body = _strip(c) if strip_html else c
183
- f.write(f"{d}\n{body}\n")
184
- if i < len(entries) - 1:
185
- f.write(separator)
186
-
187
- def export_html(
188
- self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
189
- ) -> None:
190
- parts = [
191
- "<!doctype html>",
192
- '<html lang="en">',
193
- '<meta charset="utf-8">',
194
- f"<title>{html.escape(title)}</title>",
195
- "<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
196
- "article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
197
- "<body>",
198
- f"<h1>{html.escape(title)}</h1>",
199
- ]
200
- for d, c in entries:
201
- parts.append(
202
- f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
203
- )
204
- parts.append("</body></html>")
205
-
206
- with open(file_path, "w", encoding="utf-8") as f:
207
- f.write("\n".join(parts))
208
-
209
- def export_by_extension(self, file_path: str) -> None:
210
- entries = self.get_all_entries()
211
- ext = os.path.splitext(file_path)[1].lower()
212
-
213
- if ext == ".json":
214
- self.export_json(entries, file_path)
215
- elif ext == ".csv":
216
- self.export_csv(entries, file_path)
217
- elif ext == ".txt":
218
- self.export_txt(entries, file_path)
219
- elif ext in {".html", ".htm"}:
220
- self.export_html(entries, file_path)
221
- else:
222
- raise ValueError(f"Unsupported extension: {ext}")
223
-
224
- def close(self) -> None:
225
- if self.conn is not None:
226
- self.conn.close()
227
- self.conn = None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes