bouquin 0.2.1.7__tar.gz → 0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bouquin-0.2.1.7 → bouquin-0.3}/PKG-INFO +4 -2
- {bouquin-0.2.1.7 → bouquin-0.3}/README.md +3 -1
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/db.py +251 -12
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/find_bar.py +7 -9
- bouquin-0.3/bouquin/flow_layout.py +88 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/history_dialog.py +14 -10
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/key_prompt.py +5 -3
- bouquin-0.3/bouquin/locales/en.json +136 -0
- bouquin-0.3/bouquin/locales/fr.json +136 -0
- bouquin-0.3/bouquin/locales/it.json +135 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/lock_overlay.py +3 -4
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/main.py +2 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/main_window.py +222 -71
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/markdown_editor.py +148 -15
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/markdown_highlighter.py +25 -16
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/save_dialog.py +5 -5
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/search.py +3 -1
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/settings.py +8 -1
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/settings_dialog.py +76 -40
- bouquin-0.3/bouquin/strings.py +39 -0
- bouquin-0.3/bouquin/tag_browser.py +235 -0
- bouquin-0.3/bouquin/tags_widget.py +259 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/theme.py +1 -1
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/toolbar.py +19 -17
- {bouquin-0.2.1.7 → bouquin-0.3}/pyproject.toml +6 -1
- {bouquin-0.2.1.7 → bouquin-0.3}/LICENSE +0 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/__init__.py +0 -0
- {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
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
|
|
@@ -49,6 +49,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
49
49
|
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
|
50
50
|
* Images are supported
|
|
51
51
|
* Search all pages, or find text on page (Ctrl+F)
|
|
52
|
+
* Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
|
|
52
53
|
* Automatic periodic saving (or explicitly save)
|
|
53
54
|
* Transparent integrity checking of the database when it opens
|
|
54
55
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
@@ -57,7 +58,8 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
57
58
|
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
|
58
59
|
* Dark and light themes
|
|
59
60
|
* Automatically generate checkboxes when typing 'TODO'
|
|
60
|
-
*
|
|
61
|
+
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
|
62
|
+
* English, French and Italian locales provided
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
## How to install
|
|
@@ -29,6 +29,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
29
29
|
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
|
30
30
|
* Images are supported
|
|
31
31
|
* Search all pages, or find text on page (Ctrl+F)
|
|
32
|
+
* Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
|
|
32
33
|
* Automatic periodic saving (or explicitly save)
|
|
33
34
|
* Transparent integrity checking of the database when it opens
|
|
34
35
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
@@ -37,7 +38,8 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
37
38
|
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
|
38
39
|
* Dark and light themes
|
|
39
40
|
* Automatically generate checkboxes when typing 'TODO'
|
|
40
|
-
*
|
|
41
|
+
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
|
42
|
+
* English, French and Italian locales provided
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
## How to install
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import csv
|
|
4
|
+
import hashlib
|
|
4
5
|
import html
|
|
5
6
|
import json
|
|
6
7
|
|
|
@@ -9,7 +10,34 @@ from pathlib import Path
|
|
|
9
10
|
from sqlcipher3 import dbapi2 as sqlite
|
|
10
11
|
from typing import List, Sequence, Tuple
|
|
11
12
|
|
|
13
|
+
|
|
14
|
+
from . import strings
|
|
15
|
+
|
|
12
16
|
Entry = Tuple[str, str]
|
|
17
|
+
TagRow = Tuple[int, str, str]
|
|
18
|
+
|
|
19
|
+
_TAG_COLORS = [
|
|
20
|
+
"#FFB3BA", # soft red
|
|
21
|
+
"#FFDFBA", # soft orange
|
|
22
|
+
"#FFFFBA", # soft yellow
|
|
23
|
+
"#BAFFC9", # soft green
|
|
24
|
+
"#BAE1FF", # soft blue
|
|
25
|
+
"#E0BAFF", # soft purple
|
|
26
|
+
"#FFC4B3", # soft coral
|
|
27
|
+
"#FFD8B1", # soft peach
|
|
28
|
+
"#FFF1BA", # soft light yellow
|
|
29
|
+
"#E9FFBA", # soft lime
|
|
30
|
+
"#CFFFE5", # soft mint
|
|
31
|
+
"#BAFFF5", # soft aqua
|
|
32
|
+
"#BAF0FF", # soft cyan
|
|
33
|
+
"#C7E9FF", # soft sky blue
|
|
34
|
+
"#C7CEFF", # soft periwinkle
|
|
35
|
+
"#F0BAFF", # soft lavender pink
|
|
36
|
+
"#FFBAF2", # soft magenta
|
|
37
|
+
"#FFD1F0", # soft pink
|
|
38
|
+
"#EBD5C7", # soft beige
|
|
39
|
+
"#EAEAEA", # soft gray
|
|
40
|
+
]
|
|
13
41
|
|
|
14
42
|
|
|
15
43
|
@dataclass
|
|
@@ -19,6 +47,7 @@ class DBConfig:
|
|
|
19
47
|
idle_minutes: int = 15 # 0 = never lock
|
|
20
48
|
theme: str = "system"
|
|
21
49
|
move_todos: bool = False
|
|
50
|
+
locale: str = "en"
|
|
22
51
|
|
|
23
52
|
|
|
24
53
|
class DBManager:
|
|
@@ -62,8 +91,12 @@ class DBManager:
|
|
|
62
91
|
# Not OK: rows of problems returned
|
|
63
92
|
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
|
64
93
|
raise sqlite.IntegrityError(
|
|
65
|
-
"
|
|
66
|
-
+ (
|
|
94
|
+
strings._("db_sqlcipher_integrity_check_failed")
|
|
95
|
+
+ (
|
|
96
|
+
f": {details}"
|
|
97
|
+
if details
|
|
98
|
+
else f" ({len(rows)} {strings._('db_issues_reported')})"
|
|
99
|
+
)
|
|
67
100
|
)
|
|
68
101
|
|
|
69
102
|
def _ensure_schema(self) -> None:
|
|
@@ -75,7 +108,6 @@ class DBManager:
|
|
|
75
108
|
# Always keep FKs on
|
|
76
109
|
cur.execute("PRAGMA foreign_keys = ON;")
|
|
77
110
|
|
|
78
|
-
# Create new versioned schema if missing (< 0.1.5)
|
|
79
111
|
cur.executescript(
|
|
80
112
|
"""
|
|
81
113
|
CREATE TABLE IF NOT EXISTS pages (
|
|
@@ -96,6 +128,24 @@ class DBManager:
|
|
|
96
128
|
|
|
97
129
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
|
|
98
130
|
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
133
|
+
id INTEGER PRIMARY KEY,
|
|
134
|
+
name TEXT NOT NULL UNIQUE,
|
|
135
|
+
color TEXT NOT NULL
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name);
|
|
139
|
+
|
|
140
|
+
CREATE TABLE IF NOT EXISTS page_tags (
|
|
141
|
+
page_date TEXT NOT NULL, -- FK to pages.date
|
|
142
|
+
tag_id INTEGER NOT NULL, -- FK to tags.id
|
|
143
|
+
PRIMARY KEY (page_date, tag_id),
|
|
144
|
+
FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
|
|
145
|
+
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id);
|
|
99
149
|
"""
|
|
100
150
|
)
|
|
101
151
|
self.conn.commit()
|
|
@@ -115,7 +165,7 @@ class DBManager:
|
|
|
115
165
|
self.conn = None
|
|
116
166
|
self.cfg.key = new_key
|
|
117
167
|
if not self.connect():
|
|
118
|
-
raise sqlite.Error("
|
|
168
|
+
raise sqlite.Error(strings._("db_reopen_failed_after_rekey"))
|
|
119
169
|
|
|
120
170
|
def get_entry(self, date_iso: str) -> str:
|
|
121
171
|
"""
|
|
@@ -135,22 +185,31 @@ class DBManager:
|
|
|
135
185
|
|
|
136
186
|
def search_entries(self, text: str) -> list[str]:
|
|
137
187
|
"""
|
|
138
|
-
Search for entries by term
|
|
139
|
-
version of the page.
|
|
188
|
+
Search for entries by term or tag name.
|
|
189
|
+
This only works against the latest version of the page.
|
|
140
190
|
"""
|
|
141
191
|
cur = self.conn.cursor()
|
|
142
|
-
|
|
192
|
+
q = text.strip()
|
|
193
|
+
pattern = f"%{q.lower()}%"
|
|
194
|
+
|
|
143
195
|
rows = cur.execute(
|
|
144
196
|
"""
|
|
145
|
-
SELECT p.date, v.content
|
|
197
|
+
SELECT DISTINCT p.date, v.content
|
|
146
198
|
FROM pages AS p
|
|
147
199
|
JOIN versions AS v
|
|
148
200
|
ON v.id = p.current_version_id
|
|
201
|
+
LEFT JOIN page_tags pt
|
|
202
|
+
ON pt.page_date = p.date
|
|
203
|
+
LEFT JOIN tags t
|
|
204
|
+
ON t.id = pt.tag_id
|
|
149
205
|
WHERE TRIM(v.content) <> ''
|
|
150
|
-
AND
|
|
206
|
+
AND (
|
|
207
|
+
LOWER(v.content) LIKE ?
|
|
208
|
+
OR LOWER(COALESCE(t.name, '')) LIKE ?
|
|
209
|
+
)
|
|
151
210
|
ORDER BY p.date DESC;
|
|
152
211
|
""",
|
|
153
|
-
(pattern,),
|
|
212
|
+
(pattern, pattern),
|
|
154
213
|
).fetchall()
|
|
155
214
|
return [(r[0], r[1]) for r in rows]
|
|
156
215
|
|
|
@@ -251,7 +310,9 @@ class DBManager:
|
|
|
251
310
|
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
|
252
311
|
).fetchone()
|
|
253
312
|
if row is None or row["date"] != date_iso:
|
|
254
|
-
raise ValueError(
|
|
313
|
+
raise ValueError(
|
|
314
|
+
strings._("db_version_id_does_not_belong_to_the_given_date")
|
|
315
|
+
)
|
|
255
316
|
|
|
256
317
|
with self.conn:
|
|
257
318
|
cur.execute(
|
|
@@ -375,7 +436,185 @@ class DBManager:
|
|
|
375
436
|
cur = self.conn.cursor()
|
|
376
437
|
cur.execute("VACUUM")
|
|
377
438
|
except Exception as e:
|
|
378
|
-
print(f"
|
|
439
|
+
print(f"{strings._('error')}: {e}")
|
|
440
|
+
|
|
441
|
+
# -------- Tags: helpers -------------------------------------------
|
|
442
|
+
|
|
443
|
+
def _default_tag_colour(self, name: str) -> str:
|
|
444
|
+
"""
|
|
445
|
+
Deterministically pick a colour for a tag name from a small palette.
|
|
446
|
+
"""
|
|
447
|
+
if not name:
|
|
448
|
+
return "#CCCCCC"
|
|
449
|
+
h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16)
|
|
450
|
+
return _TAG_COLORS[h % len(_TAG_COLORS)]
|
|
451
|
+
|
|
452
|
+
# -------- Tags: per-page -------------------------------------------
|
|
453
|
+
|
|
454
|
+
def get_tags_for_page(self, date_iso: str) -> list[TagRow]:
|
|
455
|
+
"""
|
|
456
|
+
Return (id, name, color) for all tags attached to this page/date.
|
|
457
|
+
"""
|
|
458
|
+
cur = self.conn.cursor()
|
|
459
|
+
rows = cur.execute(
|
|
460
|
+
"""
|
|
461
|
+
SELECT t.id, t.name, t.color
|
|
462
|
+
FROM page_tags pt
|
|
463
|
+
JOIN tags t ON t.id = pt.tag_id
|
|
464
|
+
WHERE pt.page_date = ?
|
|
465
|
+
ORDER BY LOWER(t.name);
|
|
466
|
+
""",
|
|
467
|
+
(date_iso,),
|
|
468
|
+
).fetchall()
|
|
469
|
+
return [(r[0], r[1], r[2]) for r in rows]
|
|
470
|
+
|
|
471
|
+
def set_tags_for_page(self, date_iso: str, tag_names: Sequence[str]) -> None:
|
|
472
|
+
"""
|
|
473
|
+
Replace the tag set for a page with the given names.
|
|
474
|
+
Creates new tags as needed (with auto colours).
|
|
475
|
+
Tags are case-insensitive - reuses existing tag if found with different case.
|
|
476
|
+
"""
|
|
477
|
+
# Normalise + dedupe (case-insensitive)
|
|
478
|
+
clean_names = []
|
|
479
|
+
seen = set()
|
|
480
|
+
for name in tag_names:
|
|
481
|
+
name = name.strip()
|
|
482
|
+
if not name:
|
|
483
|
+
continue
|
|
484
|
+
if name.lower() in seen:
|
|
485
|
+
continue
|
|
486
|
+
seen.add(name.lower())
|
|
487
|
+
clean_names.append(name)
|
|
488
|
+
|
|
489
|
+
with self.conn:
|
|
490
|
+
cur = self.conn.cursor()
|
|
491
|
+
|
|
492
|
+
# Ensure the page row exists even if there's no content yet
|
|
493
|
+
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
|
|
494
|
+
|
|
495
|
+
if not clean_names:
|
|
496
|
+
# Just clear all tags for this page
|
|
497
|
+
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# For each tag name, check if it exists with different casing
|
|
501
|
+
# If so, reuse that existing tag; otherwise create new
|
|
502
|
+
final_tag_names = []
|
|
503
|
+
for name in clean_names:
|
|
504
|
+
# Look for existing tag (case-insensitive)
|
|
505
|
+
existing = cur.execute(
|
|
506
|
+
"SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,)
|
|
507
|
+
).fetchone()
|
|
508
|
+
|
|
509
|
+
if existing:
|
|
510
|
+
# Use the existing tag's exact name
|
|
511
|
+
final_tag_names.append(existing["name"])
|
|
512
|
+
else:
|
|
513
|
+
# Create new tag with the provided casing
|
|
514
|
+
cur.execute(
|
|
515
|
+
"""
|
|
516
|
+
INSERT OR IGNORE INTO tags(name, color)
|
|
517
|
+
VALUES (?, ?);
|
|
518
|
+
""",
|
|
519
|
+
(name, self._default_tag_colour(name)),
|
|
520
|
+
)
|
|
521
|
+
final_tag_names.append(name)
|
|
522
|
+
|
|
523
|
+
# Lookup ids for the final tag names
|
|
524
|
+
placeholders = ",".join("?" for _ in final_tag_names)
|
|
525
|
+
rows = cur.execute(
|
|
526
|
+
f"""
|
|
527
|
+
SELECT id, name
|
|
528
|
+
FROM tags
|
|
529
|
+
WHERE name IN ({placeholders});
|
|
530
|
+
""",
|
|
531
|
+
tuple(final_tag_names),
|
|
532
|
+
).fetchall()
|
|
533
|
+
ids_by_name = {r["name"]: r["id"] for r in rows}
|
|
534
|
+
|
|
535
|
+
# Reset page_tags for this page
|
|
536
|
+
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
|
|
537
|
+
for name in final_tag_names:
|
|
538
|
+
tag_id = ids_by_name.get(name)
|
|
539
|
+
if tag_id is not None:
|
|
540
|
+
cur.execute(
|
|
541
|
+
"""
|
|
542
|
+
INSERT OR IGNORE INTO page_tags(page_date, tag_id)
|
|
543
|
+
VALUES (?, ?);
|
|
544
|
+
""",
|
|
545
|
+
(date_iso, tag_id),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# -------- Tags: global management ----------------------------------
|
|
549
|
+
|
|
550
|
+
def list_tags(self) -> list[TagRow]:
|
|
551
|
+
"""
|
|
552
|
+
Return all tags in the database.
|
|
553
|
+
"""
|
|
554
|
+
cur = self.conn.cursor()
|
|
555
|
+
rows = cur.execute(
|
|
556
|
+
"""
|
|
557
|
+
SELECT id, name, color
|
|
558
|
+
FROM tags
|
|
559
|
+
ORDER BY LOWER(name);
|
|
560
|
+
"""
|
|
561
|
+
).fetchall()
|
|
562
|
+
return [(r[0], r[1], r[2]) for r in rows]
|
|
563
|
+
|
|
564
|
+
def update_tag(self, tag_id: int, name: str, color: str) -> None:
|
|
565
|
+
"""
|
|
566
|
+
Update a tag's name and colour.
|
|
567
|
+
"""
|
|
568
|
+
name = name.strip()
|
|
569
|
+
color = color.strip() or "#CCCCCC"
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
with self.conn:
|
|
573
|
+
cur = self.conn.cursor()
|
|
574
|
+
cur.execute(
|
|
575
|
+
"""
|
|
576
|
+
UPDATE tags
|
|
577
|
+
SET name = ?, color = ?
|
|
578
|
+
WHERE id = ?;
|
|
579
|
+
""",
|
|
580
|
+
(name, color, tag_id),
|
|
581
|
+
)
|
|
582
|
+
except sqlite.IntegrityError as e:
|
|
583
|
+
if "UNIQUE constraint failed: tags.name" in str(e):
|
|
584
|
+
raise sqlite.IntegrityError(
|
|
585
|
+
strings._("tag_already_exists_with_that_name")
|
|
586
|
+
) from e
|
|
587
|
+
|
|
588
|
+
def delete_tag(self, tag_id: int) -> None:
|
|
589
|
+
"""
|
|
590
|
+
Delete a tag entirely (removes it from all pages).
|
|
591
|
+
"""
|
|
592
|
+
with self.conn:
|
|
593
|
+
cur = self.conn.cursor()
|
|
594
|
+
cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,))
|
|
595
|
+
cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,))
|
|
596
|
+
|
|
597
|
+
def get_pages_for_tag(self, tag_name: str) -> list[Entry]:
|
|
598
|
+
"""
|
|
599
|
+
Return (date, content) for pages that have the given tag.
|
|
600
|
+
"""
|
|
601
|
+
cur = self.conn.cursor()
|
|
602
|
+
rows = cur.execute(
|
|
603
|
+
"""
|
|
604
|
+
SELECT p.date, v.content
|
|
605
|
+
FROM pages AS p
|
|
606
|
+
JOIN versions AS v
|
|
607
|
+
ON v.id = p.current_version_id
|
|
608
|
+
JOIN page_tags pt
|
|
609
|
+
ON pt.page_date = p.date
|
|
610
|
+
JOIN tags t
|
|
611
|
+
ON t.id = pt.tag_id
|
|
612
|
+
WHERE LOWER(t.name) = LOWER(?)
|
|
613
|
+
ORDER BY p.date DESC;
|
|
614
|
+
""",
|
|
615
|
+
(tag_name,),
|
|
616
|
+
).fetchall()
|
|
617
|
+
return [(r[0], r[1]) for r in rows]
|
|
379
618
|
|
|
380
619
|
def close(self) -> None:
|
|
381
620
|
if self.conn is not None:
|
|
@@ -17,6 +17,8 @@ from PySide6.QtWidgets import (
|
|
|
17
17
|
QTextEdit,
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
+
from . import strings
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
class FindBar(QWidget):
|
|
22
24
|
"""Widget for finding text in the Editor"""
|
|
@@ -41,17 +43,17 @@ class FindBar(QWidget):
|
|
|
41
43
|
layout = QHBoxLayout(self)
|
|
42
44
|
layout.setContentsMargins(6, 0, 6, 0)
|
|
43
45
|
|
|
44
|
-
layout.addWidget(QLabel("
|
|
46
|
+
layout.addWidget(QLabel(strings._("find")))
|
|
45
47
|
|
|
46
48
|
self.edit = QLineEdit(self)
|
|
47
|
-
self.edit.setPlaceholderText("
|
|
49
|
+
self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
|
|
48
50
|
layout.addWidget(self.edit)
|
|
49
51
|
|
|
50
|
-
self.case = QCheckBox("
|
|
52
|
+
self.case = QCheckBox(strings._("find_bar_match_case"), self)
|
|
51
53
|
layout.addWidget(self.case)
|
|
52
54
|
|
|
53
|
-
self.prevBtn = QPushButton("
|
|
54
|
-
self.nextBtn = QPushButton("
|
|
55
|
+
self.prevBtn = QPushButton(strings._("previous"), self)
|
|
56
|
+
self.nextBtn = QPushButton(strings._("next"), self)
|
|
55
57
|
self.closeBtn = QPushButton("✕", self)
|
|
56
58
|
self.closeBtn.setFlat(True)
|
|
57
59
|
layout.addWidget(self.prevBtn)
|
|
@@ -120,8 +122,6 @@ class FindBar(QWidget):
|
|
|
120
122
|
return flags
|
|
121
123
|
|
|
122
124
|
def find_next(self):
|
|
123
|
-
if not self.editor:
|
|
124
|
-
return
|
|
125
125
|
txt = self.edit.text()
|
|
126
126
|
if not txt:
|
|
127
127
|
return
|
|
@@ -147,8 +147,6 @@ class FindBar(QWidget):
|
|
|
147
147
|
self._update_highlight()
|
|
148
148
|
|
|
149
149
|
def find_prev(self):
|
|
150
|
-
if not self.editor:
|
|
151
|
-
return
|
|
152
150
|
txt = self.edit.text()
|
|
153
151
|
if not txt:
|
|
154
152
|
return
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
|
4
|
+
from PySide6.QtWidgets import QLayout
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FlowLayout(QLayout):
|
|
8
|
+
def __init__(
|
|
9
|
+
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
|
|
10
|
+
):
|
|
11
|
+
super().__init__(parent)
|
|
12
|
+
self._items = []
|
|
13
|
+
self._hspace = hspacing
|
|
14
|
+
self._vspace = vspacing
|
|
15
|
+
self.setContentsMargins(margin, margin, margin, margin)
|
|
16
|
+
|
|
17
|
+
def addItem(self, item):
|
|
18
|
+
self._items.append(item)
|
|
19
|
+
|
|
20
|
+
def itemAt(self, index):
|
|
21
|
+
if 0 <= index < len(self._items):
|
|
22
|
+
return self._items[index]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def takeAt(self, index):
|
|
26
|
+
if 0 <= index < len(self._items):
|
|
27
|
+
return self._items.pop(index)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def count(self):
|
|
31
|
+
return len(self._items)
|
|
32
|
+
|
|
33
|
+
def expandingDirections(self):
|
|
34
|
+
return Qt.Orientations(Qt.Orientation(0))
|
|
35
|
+
|
|
36
|
+
def hasHeightForWidth(self):
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def heightForWidth(self, width: int) -> int:
|
|
40
|
+
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
|
41
|
+
|
|
42
|
+
def setGeometry(self, rect: QRect):
|
|
43
|
+
super().setGeometry(rect)
|
|
44
|
+
self._do_layout(rect, test_only=False)
|
|
45
|
+
|
|
46
|
+
def sizeHint(self) -> QSize:
|
|
47
|
+
return self.minimumSize()
|
|
48
|
+
|
|
49
|
+
def minimumSize(self) -> QSize:
|
|
50
|
+
size = QSize()
|
|
51
|
+
for item in self._items:
|
|
52
|
+
size = size.expandedTo(item.minimumSize())
|
|
53
|
+
left, top, right, bottom = self.getContentsMargins()
|
|
54
|
+
size += QSize(left + right, top + bottom)
|
|
55
|
+
return size
|
|
56
|
+
|
|
57
|
+
def _do_layout(self, rect: QRect, test_only: bool) -> int:
|
|
58
|
+
x = rect.x()
|
|
59
|
+
y = rect.y()
|
|
60
|
+
line_height = 0
|
|
61
|
+
|
|
62
|
+
left, top, right, bottom = self.getContentsMargins()
|
|
63
|
+
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
|
|
64
|
+
x = effective_rect.x()
|
|
65
|
+
y = effective_rect.y()
|
|
66
|
+
max_right = effective_rect.right()
|
|
67
|
+
|
|
68
|
+
for item in self._items:
|
|
69
|
+
wid = item.widget()
|
|
70
|
+
if wid is None or not wid.isVisible():
|
|
71
|
+
continue
|
|
72
|
+
space_x = self._hspace
|
|
73
|
+
space_y = self._vspace
|
|
74
|
+
next_x = x + item.sizeHint().width() + space_x
|
|
75
|
+
if next_x - space_x > max_right and line_height > 0:
|
|
76
|
+
# Wrap
|
|
77
|
+
x = effective_rect.x()
|
|
78
|
+
y = y + line_height + space_y
|
|
79
|
+
next_x = x + item.sizeHint().width() + space_x
|
|
80
|
+
line_height = 0
|
|
81
|
+
|
|
82
|
+
if not test_only:
|
|
83
|
+
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
|
84
|
+
|
|
85
|
+
x = next_x
|
|
86
|
+
line_height = max(line_height, item.sizeHint().height())
|
|
87
|
+
|
|
88
|
+
return y + line_height - rect.y() + bottom
|
|
@@ -15,6 +15,8 @@ from PySide6.QtWidgets import (
|
|
|
15
15
|
QTabWidget,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
from . import strings
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
def _markdown_to_text(s: str) -> str:
|
|
20
22
|
"""Convert markdown to plain text for diff comparison."""
|
|
@@ -43,7 +45,9 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
|
|
43
45
|
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
|
44
46
|
a = _markdown_to_text(old_md).splitlines()
|
|
45
47
|
b = _markdown_to_text(new_md).splitlines()
|
|
46
|
-
ud = difflib.unified_diff(
|
|
48
|
+
ud = difflib.unified_diff(
|
|
49
|
+
a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm=""
|
|
50
|
+
)
|
|
47
51
|
lines = []
|
|
48
52
|
for line in ud:
|
|
49
53
|
if line.startswith("+") and not line.startswith("+++"):
|
|
@@ -67,7 +71,7 @@ class HistoryDialog(QDialog):
|
|
|
67
71
|
|
|
68
72
|
def __init__(self, db, date_iso: str, parent=None):
|
|
69
73
|
super().__init__(parent)
|
|
70
|
-
self.setWindowTitle(f"
|
|
74
|
+
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
|
71
75
|
self._db = db
|
|
72
76
|
self._date = date_iso
|
|
73
77
|
self._versions = [] # list[dict] from DB
|
|
@@ -88,8 +92,8 @@ class HistoryDialog(QDialog):
|
|
|
88
92
|
self.preview.setOpenExternalLinks(True)
|
|
89
93
|
self.diff = QTextBrowser()
|
|
90
94
|
self.diff.setOpenExternalLinks(False)
|
|
91
|
-
self.tabs.addTab(self.preview, "
|
|
92
|
-
self.tabs.addTab(self.diff, "
|
|
95
|
+
self.tabs.addTab(self.preview, strings._("history_dialog_preview"))
|
|
96
|
+
self.tabs.addTab(self.diff, strings._("history_dialog_diff"))
|
|
93
97
|
self.tabs.setMinimumSize(500, 650)
|
|
94
98
|
top.addWidget(self.tabs, 2)
|
|
95
99
|
|
|
@@ -98,9 +102,9 @@ class HistoryDialog(QDialog):
|
|
|
98
102
|
# Buttons
|
|
99
103
|
row = QHBoxLayout()
|
|
100
104
|
row.addStretch(1)
|
|
101
|
-
self.btn_revert = QPushButton("
|
|
105
|
+
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
|
|
102
106
|
self.btn_revert.clicked.connect(self._revert)
|
|
103
|
-
self.btn_close = QPushButton("
|
|
107
|
+
self.btn_close = QPushButton(strings._("close"))
|
|
104
108
|
self.btn_close.clicked.connect(self.reject)
|
|
105
109
|
row.addWidget(self.btn_revert)
|
|
106
110
|
row.addWidget(self.btn_close)
|
|
@@ -126,7 +130,7 @@ class HistoryDialog(QDialog):
|
|
|
126
130
|
if v.get("note"):
|
|
127
131
|
label += f" · {v['note']}"
|
|
128
132
|
if v["is_current"]:
|
|
129
|
-
label += " **(current)**"
|
|
133
|
+
label += " **(" + strings._("current") + ")**"
|
|
130
134
|
it = QListWidgetItem(label)
|
|
131
135
|
it.setData(Qt.UserRole, v["id"])
|
|
132
136
|
self.list.addItem(it)
|
|
@@ -159,8 +163,6 @@ class HistoryDialog(QDialog):
|
|
|
159
163
|
@Slot()
|
|
160
164
|
def _revert(self):
|
|
161
165
|
item = self.list.currentItem()
|
|
162
|
-
if not item:
|
|
163
|
-
return
|
|
164
166
|
sel_id = item.data(Qt.UserRole)
|
|
165
167
|
if sel_id == self._current_id:
|
|
166
168
|
return
|
|
@@ -168,6 +170,8 @@ class HistoryDialog(QDialog):
|
|
|
168
170
|
try:
|
|
169
171
|
self._db.revert_to_version(self._date, version_id=sel_id)
|
|
170
172
|
except Exception as e:
|
|
171
|
-
QMessageBox.critical(
|
|
173
|
+
QMessageBox.critical(
|
|
174
|
+
self, strings._("history_dialog_revert_failed"), str(e)
|
|
175
|
+
)
|
|
172
176
|
return
|
|
173
177
|
self.accept()
|
|
@@ -9,13 +9,15 @@ from PySide6.QtWidgets import (
|
|
|
9
9
|
QDialogButtonBox,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
from . import strings
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
class KeyPrompt(QDialog):
|
|
14
16
|
def __init__(
|
|
15
17
|
self,
|
|
16
18
|
parent=None,
|
|
17
|
-
title: str = "
|
|
18
|
-
message: str = "
|
|
19
|
+
title: str = strings._("key_prompt_enter_key"),
|
|
20
|
+
message: str = strings._("key_prompt_enter_key"),
|
|
19
21
|
):
|
|
20
22
|
"""
|
|
21
23
|
Prompt the user for the key required to decrypt the database.
|
|
@@ -30,7 +32,7 @@ class KeyPrompt(QDialog):
|
|
|
30
32
|
self.edit = QLineEdit()
|
|
31
33
|
self.edit.setEchoMode(QLineEdit.Password)
|
|
32
34
|
v.addWidget(self.edit)
|
|
33
|
-
toggle = QPushButton("
|
|
35
|
+
toggle = QPushButton(strings._("show"))
|
|
34
36
|
toggle.setCheckable(True)
|
|
35
37
|
toggle.toggled.connect(
|
|
36
38
|
lambda c: self.edit.setEchoMode(
|