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.
Files changed (28) hide show
  1. {bouquin-0.2.1.7 → bouquin-0.3}/PKG-INFO +4 -2
  2. {bouquin-0.2.1.7 → bouquin-0.3}/README.md +3 -1
  3. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/db.py +251 -12
  4. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/find_bar.py +7 -9
  5. bouquin-0.3/bouquin/flow_layout.py +88 -0
  6. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/history_dialog.py +14 -10
  7. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/key_prompt.py +5 -3
  8. bouquin-0.3/bouquin/locales/en.json +136 -0
  9. bouquin-0.3/bouquin/locales/fr.json +136 -0
  10. bouquin-0.3/bouquin/locales/it.json +135 -0
  11. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/lock_overlay.py +3 -4
  12. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/main.py +2 -0
  13. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/main_window.py +222 -71
  14. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/markdown_editor.py +148 -15
  15. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/markdown_highlighter.py +25 -16
  16. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/save_dialog.py +5 -5
  17. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/search.py +3 -1
  18. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/settings.py +8 -1
  19. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/settings_dialog.py +76 -40
  20. bouquin-0.3/bouquin/strings.py +39 -0
  21. bouquin-0.3/bouquin/tag_browser.py +235 -0
  22. bouquin-0.3/bouquin/tags_widget.py +259 -0
  23. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/theme.py +1 -1
  24. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/toolbar.py +19 -17
  25. {bouquin-0.2.1.7 → bouquin-0.3}/pyproject.toml +6 -1
  26. {bouquin-0.2.1.7 → bouquin-0.3}/LICENSE +0 -0
  27. {bouquin-0.2.1.7 → bouquin-0.3}/bouquin/__init__.py +0 -0
  28. {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.2.1.7
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
- * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
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
- * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
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
- "SQLCipher integrity check failed"
66
- + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
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("Re-open failed after rekey")
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. This only works against the latest
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
- pattern = f"%{text}%"
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 v.content LIKE LOWER(?) ESCAPE '\\'
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("version_id does not belong to the given date")
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"Error: {e}")
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("Find:"))
46
+ layout.addWidget(QLabel(strings._("find")))
45
47
 
46
48
  self.edit = QLineEdit(self)
47
- self.edit.setPlaceholderText("Type to search")
49
+ self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
48
50
  layout.addWidget(self.edit)
49
51
 
50
- self.case = QCheckBox("Match case", self)
52
+ self.case = QCheckBox(strings._("find_bar_match_case"), self)
51
53
  layout.addWidget(self.case)
52
54
 
53
- self.prevBtn = QPushButton("Prev", self)
54
- self.nextBtn = QPushButton("Next", self)
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(a, b, fromfile="current", tofile="selected", lineterm="")
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"History — {date_iso}")
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, "Preview")
92
- self.tabs.addTab(self.diff, "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("Revert to Selected")
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("Close")
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(self, "Revert failed", str(e))
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 = "Enter key",
18
- message: str = "Enter key",
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("Show")
35
+ toggle = QPushButton(strings._("show"))
34
36
  toggle.setCheckable(True)
35
37
  toggle.toggled.connect(
36
38
  lambda c: self.edit.setEchoMode(