bouquin 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl
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/db.py +251 -26
- bouquin/history_dialog.py +179 -0
- bouquin/main_window.py +230 -34
- bouquin/save_dialog.py +35 -0
- bouquin/settings.py +3 -1
- bouquin/settings_dialog.py +47 -14
- bouquin/toolbar.py +9 -0
- {bouquin-0.1.3.dist-info → bouquin-0.1.5.dist-info}/METADATA +2 -1
- bouquin-0.1.5.dist-info/RECORD +18 -0
- bouquin-0.1.3.dist-info/RECORD +0 -16
- {bouquin-0.1.3.dist-info → bouquin-0.1.5.dist-info}/LICENSE +0 -0
- {bouquin-0.1.3.dist-info → bouquin-0.1.5.dist-info}/WHEEL +0 -0
- {bouquin-0.1.3.dist-info → bouquin-0.1.5.dist-info}/entry_points.txt +0 -0
bouquin/db.py
CHANGED
|
@@ -17,6 +17,7 @@ Entry = Tuple[str, str]
|
|
|
17
17
|
class DBConfig:
|
|
18
18
|
path: Path
|
|
19
19
|
key: str
|
|
20
|
+
idle_minutes: int = 15 # 0 = never lock
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class DBManager:
|
|
@@ -25,14 +26,17 @@ class DBManager:
|
|
|
25
26
|
self.conn: sqlite.Connection | None = None
|
|
26
27
|
|
|
27
28
|
def connect(self) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Open, decrypt and install schema on the database.
|
|
31
|
+
"""
|
|
28
32
|
# Ensure parent dir exists
|
|
29
33
|
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
34
|
self.conn = sqlite.connect(str(self.cfg.path))
|
|
31
35
|
self.conn.row_factory = sqlite.Row
|
|
32
36
|
cur = self.conn.cursor()
|
|
33
37
|
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
|
34
|
-
cur.execute("PRAGMA
|
|
35
|
-
|
|
38
|
+
cur.execute("PRAGMA foreign_keys = ON;")
|
|
39
|
+
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
|
36
40
|
try:
|
|
37
41
|
self._integrity_ok()
|
|
38
42
|
except Exception:
|
|
@@ -43,15 +47,18 @@ class DBManager:
|
|
|
43
47
|
return True
|
|
44
48
|
|
|
45
49
|
def _integrity_ok(self) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Runs the cipher_integrity_check PRAGMA on the database.
|
|
52
|
+
"""
|
|
46
53
|
cur = self.conn.cursor()
|
|
47
54
|
cur.execute("PRAGMA cipher_integrity_check;")
|
|
48
55
|
rows = cur.fetchall()
|
|
49
56
|
|
|
50
|
-
# OK
|
|
57
|
+
# OK: nothing returned
|
|
51
58
|
if not rows:
|
|
52
59
|
return
|
|
53
60
|
|
|
54
|
-
# Not OK
|
|
61
|
+
# Not OK: rows of problems returned
|
|
55
62
|
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
|
56
63
|
raise sqlite.IntegrityError(
|
|
57
64
|
"SQLCipher integrity check failed"
|
|
@@ -59,16 +66,62 @@ class DBManager:
|
|
|
59
66
|
)
|
|
60
67
|
|
|
61
68
|
def _ensure_schema(self) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Install the expected schema on the database.
|
|
71
|
+
We also handle upgrades here.
|
|
72
|
+
"""
|
|
62
73
|
cur = self.conn.cursor()
|
|
63
|
-
|
|
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(
|
|
64
79
|
"""
|
|
65
|
-
CREATE TABLE IF NOT EXISTS
|
|
66
|
-
date TEXT PRIMARY KEY,
|
|
67
|
-
|
|
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
|
|
68
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);
|
|
69
98
|
"""
|
|
70
99
|
)
|
|
71
|
-
|
|
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;")
|
|
72
125
|
self.conn.commit()
|
|
73
126
|
|
|
74
127
|
def rekey(self, new_key: str) -> None:
|
|
@@ -91,42 +144,214 @@ class DBManager:
|
|
|
91
144
|
raise sqlite.Error("Re-open failed after rekey")
|
|
92
145
|
|
|
93
146
|
def get_entry(self, date_iso: str) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Get a single entry by its date.
|
|
149
|
+
"""
|
|
94
150
|
cur = self.conn.cursor()
|
|
95
|
-
cur.execute(
|
|
96
|
-
|
|
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()
|
|
97
160
|
return row[0] if row else ""
|
|
98
161
|
|
|
99
162
|
def upsert_entry(self, date_iso: str, content: str) -> None:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
""",
|
|
106
|
-
(date_iso, content),
|
|
107
|
-
)
|
|
108
|
-
self.conn.commit()
|
|
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)
|
|
109
168
|
|
|
110
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
|
+
"""
|
|
111
174
|
cur = self.conn.cursor()
|
|
112
175
|
pattern = f"%{text}%"
|
|
113
|
-
|
|
114
|
-
"
|
|
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,),
|
|
115
187
|
).fetchall()
|
|
188
|
+
return [(r[0], r[1]) for r in rows]
|
|
116
189
|
|
|
117
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
|
+
"""
|
|
118
250
|
cur = self.conn.cursor()
|
|
119
|
-
cur.execute(
|
|
120
|
-
|
|
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]
|
|
121
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 ------------------------#
|
|
122
334
|
def get_all_entries(self) -> List[Entry]:
|
|
335
|
+
"""
|
|
336
|
+
Get all entries. Used for exports.
|
|
337
|
+
"""
|
|
123
338
|
cur = self.conn.cursor()
|
|
124
|
-
rows = cur.execute(
|
|
125
|
-
|
|
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]
|
|
126
348
|
|
|
127
349
|
def export_json(
|
|
128
350
|
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
|
129
351
|
) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Export to json.
|
|
354
|
+
"""
|
|
130
355
|
data = [{"date": d, "content": c} for d, c in entries]
|
|
131
356
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
132
357
|
if pretty:
|
|
@@ -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
|
bouquin/main_window.py
CHANGED
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
|
|
7
|
+
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
|
|
8
8
|
from PySide6.QtGui import (
|
|
9
9
|
QAction,
|
|
10
10
|
QCursor,
|
|
@@ -17,8 +17,10 @@ from PySide6.QtWidgets import (
|
|
|
17
17
|
QCalendarWidget,
|
|
18
18
|
QDialog,
|
|
19
19
|
QFileDialog,
|
|
20
|
+
QLabel,
|
|
20
21
|
QMainWindow,
|
|
21
22
|
QMessageBox,
|
|
23
|
+
QPushButton,
|
|
22
24
|
QSizePolicy,
|
|
23
25
|
QSplitter,
|
|
24
26
|
QVBoxLayout,
|
|
@@ -27,13 +29,70 @@ from PySide6.QtWidgets import (
|
|
|
27
29
|
|
|
28
30
|
from .db import DBManager
|
|
29
31
|
from .editor import Editor
|
|
32
|
+
from .history_dialog import HistoryDialog
|
|
30
33
|
from .key_prompt import KeyPrompt
|
|
34
|
+
from .save_dialog import SaveDialog
|
|
31
35
|
from .search import Search
|
|
32
36
|
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
|
33
37
|
from .settings_dialog import SettingsDialog
|
|
34
38
|
from .toolbar import ToolBar
|
|
35
39
|
|
|
36
40
|
|
|
41
|
+
class _LockOverlay(QWidget):
|
|
42
|
+
def __init__(self, parent: QWidget, on_unlock: callable):
|
|
43
|
+
super().__init__(parent)
|
|
44
|
+
self.setObjectName("LockOverlay")
|
|
45
|
+
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
46
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
47
|
+
self.setGeometry(parent.rect())
|
|
48
|
+
|
|
49
|
+
self.setStyleSheet(
|
|
50
|
+
"""
|
|
51
|
+
#LockOverlay { background-color: #ccc; }
|
|
52
|
+
#LockOverlay QLabel { color: #fff; font-size: 18px; }
|
|
53
|
+
#LockOverlay QPushButton {
|
|
54
|
+
background-color: #f2f2f2;
|
|
55
|
+
color: #000;
|
|
56
|
+
padding: 6px 14px;
|
|
57
|
+
border: 1px solid #808080;
|
|
58
|
+
border-radius: 6px;
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
}
|
|
61
|
+
#LockOverlay QPushButton:hover { background-color: #ffffff; }
|
|
62
|
+
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
lay = QVBoxLayout(self)
|
|
67
|
+
lay.addStretch(1)
|
|
68
|
+
|
|
69
|
+
msg = QLabel("Locked due to inactivity")
|
|
70
|
+
msg.setAlignment(Qt.AlignCenter)
|
|
71
|
+
|
|
72
|
+
self._btn = QPushButton("Unlock")
|
|
73
|
+
self._btn.setFixedWidth(200)
|
|
74
|
+
self._btn.setCursor(Qt.PointingHandCursor)
|
|
75
|
+
self._btn.setAutoDefault(True)
|
|
76
|
+
self._btn.setDefault(True)
|
|
77
|
+
self._btn.clicked.connect(on_unlock)
|
|
78
|
+
|
|
79
|
+
lay.addWidget(msg, 0, Qt.AlignCenter)
|
|
80
|
+
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
|
81
|
+
lay.addStretch(1)
|
|
82
|
+
|
|
83
|
+
self.hide() # start hidden
|
|
84
|
+
|
|
85
|
+
# keep overlay sized with its parent
|
|
86
|
+
def eventFilter(self, obj, event):
|
|
87
|
+
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
|
88
|
+
self.setGeometry(obj.rect())
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def showEvent(self, e):
|
|
92
|
+
super().showEvent(e)
|
|
93
|
+
self._btn.setFocus()
|
|
94
|
+
|
|
95
|
+
|
|
37
96
|
class MainWindow(QMainWindow):
|
|
38
97
|
def __init__(self):
|
|
39
98
|
super().__init__()
|
|
@@ -77,18 +136,19 @@ class MainWindow(QMainWindow):
|
|
|
77
136
|
self.editor = Editor()
|
|
78
137
|
|
|
79
138
|
# Toolbar for controlling styling
|
|
80
|
-
|
|
81
|
-
self.addToolBar(
|
|
139
|
+
self.toolBar = ToolBar()
|
|
140
|
+
self.addToolBar(self.toolBar)
|
|
82
141
|
# Wire toolbar intents to editor methods
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
142
|
+
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
|
143
|
+
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
|
144
|
+
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
|
145
|
+
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
|
146
|
+
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
|
147
|
+
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
|
148
|
+
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
|
149
|
+
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
|
150
|
+
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
|
151
|
+
self.toolBar.historyRequested.connect(self._open_history)
|
|
92
152
|
|
|
93
153
|
split = QSplitter()
|
|
94
154
|
split.addWidget(left_panel)
|
|
@@ -100,16 +160,39 @@ class MainWindow(QMainWindow):
|
|
|
100
160
|
lay.addWidget(split)
|
|
101
161
|
self.setCentralWidget(container)
|
|
102
162
|
|
|
163
|
+
# Idle lock setup
|
|
164
|
+
self._idle_timer = QTimer(self)
|
|
165
|
+
self._idle_timer.setSingleShot(True)
|
|
166
|
+
self._idle_timer.timeout.connect(self._enter_lock)
|
|
167
|
+
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
|
|
168
|
+
self._idle_timer.start()
|
|
169
|
+
|
|
170
|
+
# full-window overlay that sits on top of the central widget
|
|
171
|
+
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
|
172
|
+
self.centralWidget().installEventFilter(self._lock_overlay)
|
|
173
|
+
|
|
174
|
+
self._locked = False
|
|
175
|
+
|
|
176
|
+
# reset idle timer on any key press anywhere in the app
|
|
177
|
+
from PySide6.QtWidgets import QApplication
|
|
178
|
+
|
|
179
|
+
QApplication.instance().installEventFilter(self)
|
|
180
|
+
|
|
103
181
|
# Status bar for feedback
|
|
104
182
|
self.statusBar().showMessage("Ready", 800)
|
|
105
183
|
|
|
106
184
|
# Menu bar (File)
|
|
107
185
|
mb = self.menuBar()
|
|
108
186
|
file_menu = mb.addMenu("&File")
|
|
109
|
-
act_save = QAction("&Save", self)
|
|
187
|
+
act_save = QAction("&Save a version", self)
|
|
110
188
|
act_save.setShortcut("Ctrl+S")
|
|
111
189
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
112
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)
|
|
113
196
|
act_settings = QAction("Settin&gs", self)
|
|
114
197
|
act_settings.setShortcut("Ctrl+G")
|
|
115
198
|
act_settings.triggered.connect(self._open_settings)
|
|
@@ -155,6 +238,12 @@ class MainWindow(QMainWindow):
|
|
|
155
238
|
act_docs.triggered.connect(self._open_docs)
|
|
156
239
|
help_menu.addAction(act_docs)
|
|
157
240
|
self.addAction(act_docs)
|
|
241
|
+
act_bugs = QAction("Report a bug", self)
|
|
242
|
+
act_bugs.setShortcut("Ctrl+R")
|
|
243
|
+
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
|
|
244
|
+
act_bugs.triggered.connect(self._open_bugs)
|
|
245
|
+
help_menu.addAction(act_bugs)
|
|
246
|
+
self.addAction(act_bugs)
|
|
158
247
|
|
|
159
248
|
# Autosave
|
|
160
249
|
self._dirty = False
|
|
@@ -249,7 +338,7 @@ class MainWindow(QMainWindow):
|
|
|
249
338
|
|
|
250
339
|
def _on_text_changed(self):
|
|
251
340
|
self._dirty = True
|
|
252
|
-
self._save_timer.start(
|
|
341
|
+
self._save_timer.start(10000) # autosave after idle
|
|
253
342
|
|
|
254
343
|
def _adjust_day(self, delta: int):
|
|
255
344
|
"""Move selection by delta days (negative for previous)."""
|
|
@@ -277,7 +366,7 @@ class MainWindow(QMainWindow):
|
|
|
277
366
|
# Now load the newly selected date
|
|
278
367
|
self._load_selected_date()
|
|
279
368
|
|
|
280
|
-
def _save_date(self, date_iso: str, explicit: bool = False):
|
|
369
|
+
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
|
281
370
|
"""
|
|
282
371
|
Save editor contents into the given date. Shows status on success.
|
|
283
372
|
explicit=True means user invoked Save: show feedback even if nothing changed.
|
|
@@ -286,7 +375,7 @@ class MainWindow(QMainWindow):
|
|
|
286
375
|
return
|
|
287
376
|
text = self.editor.toHtml()
|
|
288
377
|
try:
|
|
289
|
-
self.db.
|
|
378
|
+
self.db.save_new_version(date_iso, text, note)
|
|
290
379
|
except Exception as e:
|
|
291
380
|
QMessageBox.critical(self, "Save Error", str(e))
|
|
292
381
|
return
|
|
@@ -300,27 +389,65 @@ class MainWindow(QMainWindow):
|
|
|
300
389
|
)
|
|
301
390
|
|
|
302
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"
|
|
303
404
|
# Delegate to _save_date for the currently selected date
|
|
304
|
-
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()
|
|
305
418
|
|
|
419
|
+
# ----------- Settings handler ------------#
|
|
306
420
|
def _open_settings(self):
|
|
307
421
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
|
308
|
-
if dlg.exec()
|
|
309
|
-
|
|
310
|
-
if new_cfg.path != self.cfg.path:
|
|
311
|
-
# Save the new path to the notebook
|
|
312
|
-
self.cfg.path = new_cfg.path
|
|
313
|
-
save_db_config(self.cfg)
|
|
314
|
-
self.db.close()
|
|
315
|
-
# Prompt again for the key for the new path
|
|
316
|
-
if not self._prompt_for_key_until_valid():
|
|
317
|
-
QMessageBox.warning(
|
|
318
|
-
self, "Reopen failed", "Could not unlock database at new path."
|
|
319
|
-
)
|
|
320
|
-
return
|
|
321
|
-
self._load_selected_date()
|
|
322
|
-
self._refresh_calendar_marks()
|
|
422
|
+
if dlg.exec() != QDialog.Accepted:
|
|
423
|
+
return
|
|
323
424
|
|
|
425
|
+
new_cfg = dlg.config
|
|
426
|
+
old_path = self.cfg.path
|
|
427
|
+
|
|
428
|
+
# Update in-memory config from the dialog
|
|
429
|
+
self.cfg.path = new_cfg.path
|
|
430
|
+
self.cfg.key = new_cfg.key
|
|
431
|
+
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
|
432
|
+
|
|
433
|
+
# Persist once
|
|
434
|
+
save_db_config(self.cfg)
|
|
435
|
+
|
|
436
|
+
# Apply idle setting immediately (restart the timer with new interval if it changed)
|
|
437
|
+
self._apply_idle_minutes(self.cfg.idle_minutes)
|
|
438
|
+
|
|
439
|
+
# If the DB path changed, reconnect
|
|
440
|
+
if self.cfg.path != old_path:
|
|
441
|
+
self.db.close()
|
|
442
|
+
if not self._prompt_for_key_until_valid(first_time=False):
|
|
443
|
+
QMessageBox.warning(
|
|
444
|
+
self, "Reopen failed", "Could not unlock database at new path."
|
|
445
|
+
)
|
|
446
|
+
return
|
|
447
|
+
self._load_selected_date()
|
|
448
|
+
self._refresh_calendar_marks()
|
|
449
|
+
|
|
450
|
+
# ------------ Window positioning --------------- #
|
|
324
451
|
def _restore_window_position(self):
|
|
325
452
|
geom = self.settings.value("main/geometry", None)
|
|
326
453
|
state = self.settings.value("main/windowState", None)
|
|
@@ -354,6 +481,7 @@ class MainWindow(QMainWindow):
|
|
|
354
481
|
# Center the window in that screen’s available area
|
|
355
482
|
self.move(r.center() - self.rect().center())
|
|
356
483
|
|
|
484
|
+
# ----------------- Export handler ----------------- #
|
|
357
485
|
@Slot()
|
|
358
486
|
def _export(self):
|
|
359
487
|
try:
|
|
@@ -402,9 +530,77 @@ class MainWindow(QMainWindow):
|
|
|
402
530
|
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
|
403
531
|
url = QUrl.fromUserInput(url_str)
|
|
404
532
|
if not QDesktopServices.openUrl(url):
|
|
405
|
-
QMessageBox.warning(
|
|
406
|
-
|
|
533
|
+
QMessageBox.warning(
|
|
534
|
+
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
|
535
|
+
)
|
|
407
536
|
|
|
537
|
+
def _open_bugs(self):
|
|
538
|
+
url_str = "https://nr.mig5.net/forms/mig5/contact"
|
|
539
|
+
url = QUrl.fromUserInput(url_str)
|
|
540
|
+
if not QDesktopServices.openUrl(url):
|
|
541
|
+
QMessageBox.warning(
|
|
542
|
+
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Idle handlers
|
|
546
|
+
def _apply_idle_minutes(self, minutes: int):
|
|
547
|
+
minutes = max(0, int(minutes))
|
|
548
|
+
if not hasattr(self, "_idle_timer"):
|
|
549
|
+
return
|
|
550
|
+
if minutes == 0:
|
|
551
|
+
self._idle_timer.stop()
|
|
552
|
+
# If you’re currently locked, unlock when user disables the timer:
|
|
553
|
+
if getattr(self, "_locked", False):
|
|
554
|
+
try:
|
|
555
|
+
self._locked = False
|
|
556
|
+
if hasattr(self, "_lock_overlay"):
|
|
557
|
+
self._lock_overlay.hide()
|
|
558
|
+
except Exception:
|
|
559
|
+
pass
|
|
560
|
+
else:
|
|
561
|
+
self._idle_timer.setInterval(minutes * 60 * 1000)
|
|
562
|
+
if not getattr(self, "_locked", False):
|
|
563
|
+
self._idle_timer.start()
|
|
564
|
+
|
|
565
|
+
def eventFilter(self, obj, event):
|
|
566
|
+
if event.type() == QEvent.KeyPress and not self._locked:
|
|
567
|
+
self._idle_timer.start()
|
|
568
|
+
return super().eventFilter(obj, event)
|
|
569
|
+
|
|
570
|
+
def _enter_lock(self):
|
|
571
|
+
if self._locked:
|
|
572
|
+
return
|
|
573
|
+
self._locked = True
|
|
574
|
+
if self.menuBar():
|
|
575
|
+
self.menuBar().setEnabled(False)
|
|
576
|
+
if self.statusBar():
|
|
577
|
+
self.statusBar().setEnabled(False)
|
|
578
|
+
tb = getattr(self, "toolBar", None)
|
|
579
|
+
if tb:
|
|
580
|
+
tb.setEnabled(False)
|
|
581
|
+
self._lock_overlay.show()
|
|
582
|
+
self._lock_overlay.raise_()
|
|
583
|
+
|
|
584
|
+
@Slot()
|
|
585
|
+
def _on_unlock_clicked(self):
|
|
586
|
+
try:
|
|
587
|
+
ok = self._prompt_for_key_until_valid(first_time=False)
|
|
588
|
+
except Exception as e:
|
|
589
|
+
QMessageBox.critical(self, "Unlock failed", str(e))
|
|
590
|
+
return
|
|
591
|
+
if ok:
|
|
592
|
+
self._locked = False
|
|
593
|
+
self._lock_overlay.hide()
|
|
594
|
+
if self.menuBar():
|
|
595
|
+
self.menuBar().setEnabled(True)
|
|
596
|
+
if self.statusBar():
|
|
597
|
+
self.statusBar().setEnabled(True)
|
|
598
|
+
tb = getattr(self, "toolBar", None)
|
|
599
|
+
if tb:
|
|
600
|
+
tb.setEnabled(True)
|
|
601
|
+
self._idle_timer.start()
|
|
602
|
+
|
|
603
|
+
# Close app handler - save window position and database
|
|
408
604
|
def closeEvent(self, event):
|
|
409
605
|
try:
|
|
410
606
|
# Save window position
|
bouquin/save_dialog.py
ADDED
|
@@ -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()
|
bouquin/settings.py
CHANGED
|
@@ -22,10 +22,12 @@ def load_db_config() -> DBConfig:
|
|
|
22
22
|
s = get_settings()
|
|
23
23
|
path = Path(s.value("db/path", str(default_db_path())))
|
|
24
24
|
key = s.value("db/key", "")
|
|
25
|
-
|
|
25
|
+
idle = s.value("db/idle_minutes", 15, type=int)
|
|
26
|
+
return DBConfig(path=path, key=key, idle_minutes=idle)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def save_db_config(cfg: DBConfig) -> None:
|
|
29
30
|
s = get_settings()
|
|
30
31
|
s.setValue("db/path", str(cfg.path))
|
|
31
32
|
s.setValue("db/key", str(cfg.key))
|
|
33
|
+
s.setValue("db/idle_minutes", str(cfg.idle_minutes))
|
bouquin/settings_dialog.py
CHANGED
|
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
|
|
|
17
17
|
QFileDialog,
|
|
18
18
|
QDialogButtonBox,
|
|
19
19
|
QSizePolicy,
|
|
20
|
+
QSpinBox,
|
|
20
21
|
QMessageBox,
|
|
21
22
|
)
|
|
22
23
|
from PySide6.QtCore import Qt, Slot
|
|
@@ -56,7 +57,7 @@ class SettingsDialog(QDialog):
|
|
|
56
57
|
form.addRow("Database path", path_row)
|
|
57
58
|
|
|
58
59
|
# Encryption settings
|
|
59
|
-
enc_group = QGroupBox("Encryption")
|
|
60
|
+
enc_group = QGroupBox("Encryption and Privacy")
|
|
60
61
|
enc = QVBoxLayout(enc_group)
|
|
61
62
|
enc.setContentsMargins(12, 8, 12, 12)
|
|
62
63
|
enc.setSpacing(6)
|
|
@@ -64,10 +65,8 @@ class SettingsDialog(QDialog):
|
|
|
64
65
|
# Checkbox to remember key
|
|
65
66
|
self.save_key_btn = QCheckBox("Remember key")
|
|
66
67
|
current_settings = load_db_config()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
else:
|
|
70
|
-
self.save_key_btn.setChecked(False)
|
|
68
|
+
self.key = current_settings.key or ""
|
|
69
|
+
self.save_key_btn.setChecked(bool(self.key))
|
|
71
70
|
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
|
72
71
|
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
|
73
72
|
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
|
@@ -100,6 +99,31 @@ class SettingsDialog(QDialog):
|
|
|
100
99
|
self.rekey_btn.clicked.connect(self._change_key)
|
|
101
100
|
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
|
102
101
|
|
|
102
|
+
self.idle_spin = QSpinBox()
|
|
103
|
+
self.idle_spin.setRange(0, 240)
|
|
104
|
+
self.idle_spin.setSingleStep(1)
|
|
105
|
+
self.idle_spin.setAccelerated(True)
|
|
106
|
+
self.idle_spin.setSuffix(" min")
|
|
107
|
+
self.idle_spin.setSpecialValueText("Never")
|
|
108
|
+
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
|
109
|
+
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
|
110
|
+
# Explanation for idle option (autolock)
|
|
111
|
+
self.idle_spin_label = QLabel(
|
|
112
|
+
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
|
113
|
+
"Set to 0 (never) to never lock."
|
|
114
|
+
)
|
|
115
|
+
self.idle_spin_label.setWordWrap(True)
|
|
116
|
+
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
|
117
|
+
# make it look secondary
|
|
118
|
+
spal = self.idle_spin_label.palette()
|
|
119
|
+
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
|
|
120
|
+
self.idle_spin_label.setPalette(spal)
|
|
121
|
+
|
|
122
|
+
spin_row = QHBoxLayout()
|
|
123
|
+
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
|
124
|
+
spin_row.addWidget(self.idle_spin_label)
|
|
125
|
+
enc.addLayout(spin_row)
|
|
126
|
+
|
|
103
127
|
# Put the group into the form so it spans the full width nicely
|
|
104
128
|
form.addRow(enc_group)
|
|
105
129
|
|
|
@@ -126,7 +150,12 @@ class SettingsDialog(QDialog):
|
|
|
126
150
|
self.path_edit.setText(p)
|
|
127
151
|
|
|
128
152
|
def _save(self):
|
|
129
|
-
self.
|
|
153
|
+
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
|
154
|
+
self._cfg = DBConfig(
|
|
155
|
+
path=Path(self.path_edit.text()),
|
|
156
|
+
key=key_to_save,
|
|
157
|
+
idle_minutes=self.idle_spin.value(),
|
|
158
|
+
)
|
|
130
159
|
save_db_config(self._cfg)
|
|
131
160
|
self.accept()
|
|
132
161
|
|
|
@@ -155,14 +184,18 @@ class SettingsDialog(QDialog):
|
|
|
155
184
|
@Slot(bool)
|
|
156
185
|
def save_key_btn_clicked(self, checked: bool):
|
|
157
186
|
if checked:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
if not self.key:
|
|
188
|
+
p1 = KeyPrompt(
|
|
189
|
+
self, title="Enter your key", message="Enter the encryption key"
|
|
190
|
+
)
|
|
191
|
+
if p1.exec() != QDialog.Accepted:
|
|
192
|
+
self.save_key_btn.blockSignals(True)
|
|
193
|
+
self.save_key_btn.setChecked(False)
|
|
194
|
+
self.save_key_btn.blockSignals(False)
|
|
195
|
+
return
|
|
196
|
+
self.key = p1.key() or ""
|
|
197
|
+
else:
|
|
198
|
+
self.key = ""
|
|
166
199
|
|
|
167
200
|
@property
|
|
168
201
|
def config(self) -> DBConfig:
|
bouquin/toolbar.py
CHANGED
|
@@ -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
|
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
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
|
|
2
|
+
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
+
bouquin/db.py,sha256=GDcv_ngezbf9DiFUhrEFN9yq4i2QHuDepG-2qQLyD80,16082
|
|
4
|
+
bouquin/editor.py,sha256=vPLqysUNinUO6gtJQ8uDxJ_BL-lcaq0IXLStlG63k4E,8042
|
|
5
|
+
bouquin/history_dialog.py,sha256=oFW1CGyqT8mzXdYuVyuBrlNVFSkvlBzajkOJ590L0go,6308
|
|
6
|
+
bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
|
|
7
|
+
bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
|
|
8
|
+
bouquin/main_window.py,sha256=su_3MXwogKNRRcovng7Ll3btoTEdkD8OZMMK7UqmPsg,22199
|
|
9
|
+
bouquin/save_dialog.py,sha256=nPLNWeImJZamNg53qE7_aeMK_p16aOiry0G4VvJsIWY,939
|
|
10
|
+
bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
|
|
11
|
+
bouquin/settings.py,sha256=GpMeJcTjdL1PFumeqdlSOi7nlgGdPTOeRbFadWYFcA0,870
|
|
12
|
+
bouquin/settings_dialog.py,sha256=pgIg2G5O092mPn5EmkKrEgtl-Tyc8dwwCyNSNEAOidA,7256
|
|
13
|
+
bouquin/toolbar.py,sha256=AsJB5zpPzUIyZkOqGcb2EJqQXelmPZ5aJX35XrrxA4g,5694
|
|
14
|
+
bouquin-0.1.5.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
15
|
+
bouquin-0.1.5.dist-info/METADATA,sha256=SM2UsBP9iC21_2YAglG50fbz_CoZ91qg6m16L8GIJmU,2546
|
|
16
|
+
bouquin-0.1.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
17
|
+
bouquin-0.1.5.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
18
|
+
bouquin-0.1.5.dist-info/RECORD,,
|
bouquin-0.1.3.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
|
|
2
|
-
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
-
bouquin/db.py,sha256=s3FDphbi6zxpHEFHnz44saZ9qAV4wU4WEiE6V95PkmI,7877
|
|
4
|
-
bouquin/editor.py,sha256=vPLqysUNinUO6gtJQ8uDxJ_BL-lcaq0IXLStlG63k4E,8042
|
|
5
|
-
bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
|
|
6
|
-
bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
|
|
7
|
-
bouquin/main_window.py,sha256=48lq5trwORpGWko6jWLGBk-_7PrtaQfZT1l-jbz67rY,15427
|
|
8
|
-
bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
|
|
9
|
-
bouquin/settings.py,sha256=aEsIIlYGwSxCVXXMpo98192QzatIIP6OvQDtcKrYWW4,742
|
|
10
|
-
bouquin/settings_dialog.py,sha256=kWR4OeeHd5uQZ6lfHtuYx3UIh_MCb-nhjHcDyhQhpKM,5747
|
|
11
|
-
bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
|
|
12
|
-
bouquin-0.1.3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
13
|
-
bouquin-0.1.3.dist-info/METADATA,sha256=y2FvqLWDTEYj1E2LCYAezvRsbycKVV71pVy9AaZv9EY,2468
|
|
14
|
-
bouquin-0.1.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
15
|
-
bouquin-0.1.3.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
16
|
-
bouquin-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|