bouquin 0.1.1__py3-none-any.whl → 0.1.3__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/main_window.py CHANGED
@@ -1,26 +1,37 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import sys
4
5
 
5
- from PySide6.QtCore import QDate, QTimer, Qt
6
- from PySide6.QtGui import QAction, QFont, QTextCharFormat
6
+ from pathlib import Path
7
+ from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
8
+ from PySide6.QtGui import (
9
+ QAction,
10
+ QCursor,
11
+ QDesktopServices,
12
+ QFont,
13
+ QGuiApplication,
14
+ QTextCharFormat,
15
+ )
7
16
  from PySide6.QtWidgets import (
8
- QDialog,
9
17
  QCalendarWidget,
18
+ QDialog,
19
+ QFileDialog,
10
20
  QMainWindow,
11
21
  QMessageBox,
12
- QPlainTextEdit,
22
+ QSizePolicy,
13
23
  QSplitter,
14
24
  QVBoxLayout,
15
25
  QWidget,
16
- QSizePolicy,
17
26
  )
18
27
 
19
28
  from .db import DBManager
20
- from .settings import APP_NAME, load_db_config, save_db_config
29
+ from .editor import Editor
21
30
  from .key_prompt import KeyPrompt
22
- from .highlighter import MarkdownHighlighter
31
+ from .search import Search
32
+ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
23
33
  from .settings_dialog import SettingsDialog
34
+ from .toolbar import ToolBar
24
35
 
25
36
 
26
37
  class MainWindow(QMainWindow):
@@ -30,9 +41,18 @@ class MainWindow(QMainWindow):
30
41
  self.setMinimumSize(1000, 650)
31
42
 
32
43
  self.cfg = load_db_config()
33
- # Always prompt for the key (we never store it)
34
- if not self._prompt_for_key_until_valid():
35
- sys.exit(1)
44
+ if not os.path.exists(self.cfg.path):
45
+ # Fresh database/first time use, so guide the user re: setting a key
46
+ first_time = True
47
+ else:
48
+ first_time = False
49
+
50
+ # Prompt for the key unless it is found in config
51
+ if not self.cfg.key:
52
+ if not self._prompt_for_key_until_valid(first_time):
53
+ sys.exit(1)
54
+ else:
55
+ self._try_connect()
36
56
 
37
57
  # ---- UI: Left fixed panel (calendar) + right editor -----------------
38
58
  self.calendar = QCalendarWidget()
@@ -40,17 +60,35 @@ class MainWindow(QMainWindow):
40
60
  self.calendar.setGridVisible(True)
41
61
  self.calendar.selectionChanged.connect(self._on_date_changed)
42
62
 
63
+ self.search = Search(self.db)
64
+ self.search.openDateRequested.connect(self._load_selected_date)
65
+
66
+ # Lock the calendar to the left panel at the top to stop it stretching
67
+ # when the main window is resized.
43
68
  left_panel = QWidget()
44
69
  left_layout = QVBoxLayout(left_panel)
45
70
  left_layout.setContentsMargins(8, 8, 8, 8)
46
71
  left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
72
+ left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
47
73
  left_layout.addStretch(1)
48
74
  left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
49
75
 
50
- self.editor = QPlainTextEdit()
51
- tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ")
52
- self.editor.setTabStopDistance(tab_w)
53
- self.highlighter = MarkdownHighlighter(self.editor.document())
76
+ # This is the note-taking editor
77
+ self.editor = Editor()
78
+
79
+ # Toolbar for controlling styling
80
+ tb = ToolBar()
81
+ self.addToolBar(tb)
82
+ # Wire toolbar intents to editor methods
83
+ tb.boldRequested.connect(self.editor.apply_weight)
84
+ tb.italicRequested.connect(self.editor.apply_italic)
85
+ tb.underlineRequested.connect(self.editor.apply_underline)
86
+ tb.strikeRequested.connect(self.editor.apply_strikethrough)
87
+ tb.codeRequested.connect(self.editor.apply_code)
88
+ tb.headingRequested.connect(self.editor.apply_heading)
89
+ tb.bulletsRequested.connect(self.editor.toggle_bullets)
90
+ tb.numbersRequested.connect(self.editor.toggle_numbers)
91
+ tb.alignRequested.connect(self.editor.setAlignment)
54
92
 
55
93
  split = QSplitter()
56
94
  split.addWidget(left_panel)
@@ -72,17 +110,21 @@ class MainWindow(QMainWindow):
72
110
  act_save.setShortcut("Ctrl+S")
73
111
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
74
112
  file_menu.addAction(act_save)
75
- act_settings = QAction("S&ettings", self)
76
- act_save.setShortcut("Ctrl+E")
113
+ act_settings = QAction("Settin&gs", self)
114
+ act_settings.setShortcut("Ctrl+G")
77
115
  act_settings.triggered.connect(self._open_settings)
78
116
  file_menu.addAction(act_settings)
117
+ act_export = QAction("&Export", self)
118
+ act_export.setShortcut("Ctrl+E")
119
+ act_export.triggered.connect(self._export)
120
+ file_menu.addAction(act_export)
79
121
  file_menu.addSeparator()
80
122
  act_quit = QAction("&Quit", self)
81
123
  act_quit.setShortcut("Ctrl+Q")
82
124
  act_quit.triggered.connect(self.close)
83
125
  file_menu.addAction(act_quit)
84
126
 
85
- # Navigate menu with next/previous day
127
+ # Navigate menu with next/previous/today
86
128
  nav_menu = mb.addMenu("&Navigate")
87
129
  act_prev = QAction("Previous Day", self)
88
130
  act_prev.setShortcut("Ctrl+P")
@@ -105,6 +147,15 @@ class MainWindow(QMainWindow):
105
147
  nav_menu.addAction(act_today)
106
148
  self.addAction(act_today)
107
149
 
150
+ # Help menu with drop-down
151
+ help_menu = mb.addMenu("&Help")
152
+ act_docs = QAction("Documentation", self)
153
+ act_docs.setShortcut("Ctrl+D")
154
+ act_docs.setShortcutContext(Qt.ApplicationShortcut)
155
+ act_docs.triggered.connect(self._open_docs)
156
+ help_menu.addAction(act_docs)
157
+ self.addAction(act_docs)
158
+
108
159
  # Autosave
109
160
  self._dirty = False
110
161
  self._save_timer = QTimer(self)
@@ -112,12 +163,18 @@ class MainWindow(QMainWindow):
112
163
  self._save_timer.timeout.connect(self._save_current)
113
164
  self.editor.textChanged.connect(self._on_text_changed)
114
165
 
115
- # First load + mark dates with content
166
+ # First load + mark dates in calendar with content
116
167
  self._load_selected_date()
117
168
  self._refresh_calendar_marks()
118
169
 
119
- # --- DB lifecycle
170
+ # Restore window position from settings
171
+ self.settings = QSettings(APP_ORG, APP_NAME)
172
+ self._restore_window_position()
173
+
120
174
  def _try_connect(self) -> bool:
175
+ """
176
+ Try to connect to the database.
177
+ """
121
178
  try:
122
179
  self.db = DBManager(self.cfg)
123
180
  ok = self.db.connect()
@@ -130,17 +187,29 @@ class MainWindow(QMainWindow):
130
187
  return False
131
188
  return ok
132
189
 
133
- def _prompt_for_key_until_valid(self) -> bool:
190
+ def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
191
+ """
192
+ Prompt for the SQLCipher key.
193
+ """
194
+ if first_time:
195
+ title = "Set an encryption key"
196
+ message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
197
+ else:
198
+ title = "Unlock encrypted notebook"
199
+ message = "Enter your key to unlock the notebook"
134
200
  while True:
135
- dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
201
+ dlg = KeyPrompt(self, title, message)
136
202
  if dlg.exec() != QDialog.Accepted:
137
203
  return False
138
204
  self.cfg.key = dlg.key()
139
205
  if self._try_connect():
140
206
  return True
141
207
 
142
- # --- Calendar marks to indicate text exists for htat day -----------------
143
208
  def _refresh_calendar_marks(self):
209
+ """
210
+ Sets a bold marker on the day to indicate that text exists
211
+ for that day.
212
+ """
144
213
  fmt_bold = QTextCharFormat()
145
214
  fmt_bold.setFontWeight(QFont.Weight.Bold)
146
215
  # Clear previous marks
@@ -161,19 +230,22 @@ class MainWindow(QMainWindow):
161
230
  d = self.calendar.selectedDate()
162
231
  return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
163
232
 
164
- def _load_selected_date(self):
165
- date_iso = self._current_date_iso()
233
+ def _load_selected_date(self, date_iso=False):
234
+ if not date_iso:
235
+ date_iso = self._current_date_iso()
166
236
  try:
167
237
  text = self.db.get_entry(date_iso)
168
238
  except Exception as e:
169
239
  QMessageBox.critical(self, "Read Error", str(e))
170
240
  return
171
241
  self.editor.blockSignals(True)
172
- self.editor.setPlainText(text)
242
+ self.editor.setHtml(text)
173
243
  self.editor.blockSignals(False)
174
244
  self._dirty = False
175
245
  # track which date the editor currently represents
176
246
  self._active_date_iso = date_iso
247
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
248
+ self.calendar.setSelectedDate(qd)
177
249
 
178
250
  def _on_text_changed(self):
179
251
  self._dirty = True
@@ -212,7 +284,7 @@ class MainWindow(QMainWindow):
212
284
  """
213
285
  if not self._dirty and not explicit:
214
286
  return
215
- text = self.editor.toPlainText()
287
+ text = self.editor.toHtml()
216
288
  try:
217
289
  self.db.upsert_entry(date_iso, text)
218
290
  except Exception as e:
@@ -249,8 +321,97 @@ class MainWindow(QMainWindow):
249
321
  self._load_selected_date()
250
322
  self._refresh_calendar_marks()
251
323
 
252
- def closeEvent(self, event): # noqa: N802
324
+ def _restore_window_position(self):
325
+ geom = self.settings.value("main/geometry", None)
326
+ state = self.settings.value("main/windowState", None)
327
+ was_max = self.settings.value("main/maximized", False, type=bool)
328
+
329
+ if geom is not None:
330
+ self.restoreGeometry(geom)
331
+ if state is not None:
332
+ self.restoreState(state)
333
+ if not self._rect_on_any_screen(self.frameGeometry()):
334
+ self._move_to_cursor_screen_center()
335
+ else:
336
+ # First run: place window on the screen where the mouse cursor is.
337
+ self._move_to_cursor_screen_center()
338
+
339
+ # If it was maximized, do that AFTER the window exists in the event loop.
340
+ if was_max:
341
+ QTimer.singleShot(0, self.showMaximized)
342
+
343
+ def _rect_on_any_screen(self, rect):
344
+ for sc in QGuiApplication.screens():
345
+ if sc.availableGeometry().intersects(rect):
346
+ return True
347
+ return False
348
+
349
+ def _move_to_cursor_screen_center(self):
350
+ screen = (
351
+ QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
352
+ )
353
+ r = screen.availableGeometry()
354
+ # Center the window in that screen’s available area
355
+ self.move(r.center() - self.rect().center())
356
+
357
+ @Slot()
358
+ def _export(self):
359
+ try:
360
+ self.export_dialog()
361
+ except Exception as e:
362
+ QMessageBox.critical(self, "Export failed", str(e))
363
+
364
+ def export_dialog(self) -> None:
365
+ filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
366
+
367
+ start_dir = os.path.join(os.path.expanduser("~"), "Documents")
368
+ filename, selected_filter = QFileDialog.getSaveFileName(
369
+ self, "Export entries", start_dir, filters
370
+ )
371
+ if not filename:
372
+ return # user cancelled
373
+
374
+ default_ext = {
375
+ "Text (*.txt)": ".txt",
376
+ "JSON (*.json)": ".json",
377
+ "CSV (*.csv)": ".csv",
378
+ "HTML (*.html)": ".html",
379
+ }.get(selected_filter, ".txt")
380
+
381
+ if not Path(filename).suffix:
382
+ filename += default_ext
383
+
384
+ try:
385
+ entries = self.db.get_all_entries()
386
+ if selected_filter.startswith("Text"):
387
+ self.db.export_txt(entries, filename)
388
+ elif selected_filter.startswith("JSON"):
389
+ self.db.export_json(entries, filename)
390
+ elif selected_filter.startswith("CSV"):
391
+ self.db.export_csv(entries, filename)
392
+ elif selected_filter.startswith("HTML"):
393
+ self.bd.export_html(entries, filename)
394
+ else:
395
+ self.bd.export_by_extension(entries, filename)
396
+
397
+ QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
398
+ except Exception as e:
399
+ QMessageBox.critical(self, "Export failed", str(e))
400
+
401
+ def _open_docs(self):
402
+ url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
403
+ url = QUrl.fromUserInput(url_str)
404
+ if not QDesktopServices.openUrl(url):
405
+ QMessageBox.warning(self, "Open Documentation",
406
+ f"Couldn't open:\n{url.toDisplayString()}")
407
+
408
+ def closeEvent(self, event):
253
409
  try:
410
+ # Save window position
411
+ self.settings.setValue("main/geometry", self.saveGeometry())
412
+ self.settings.setValue("main/windowState", self.saveState())
413
+ self.settings.setValue("main/maximized", self.isMaximized())
414
+ # Ensure we save any last pending edits to the db
254
415
  self._save_current()
255
416
  self.db.close()
256
417
  except Exception:
bouquin/search.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Iterable, Tuple
5
+
6
+ from PySide6.QtCore import Qt, Signal
7
+ from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
8
+ from PySide6.QtWidgets import (
9
+ QLabel,
10
+ QLineEdit,
11
+ QListWidget,
12
+ QListWidgetItem,
13
+ QHBoxLayout,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ # type: rows are (date_iso, content)
19
+ Row = Tuple[str, str]
20
+
21
+
22
+ class Search(QWidget):
23
+ """Encapsulates the search UI + logic and emits a signal when a result is chosen."""
24
+
25
+ openDateRequested = Signal(str)
26
+
27
+ def __init__(self, db, parent: QWidget | None = None):
28
+ super().__init__(parent)
29
+ self._db = db
30
+
31
+ self.search = QLineEdit()
32
+ self.search.setPlaceholderText("Search for notes here")
33
+ self.search.textChanged.connect(self._search)
34
+
35
+ self.results = QListWidget()
36
+ self.results.setUniformItemSizes(False)
37
+ self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
38
+ self.results.itemClicked.connect(self._open_selected)
39
+ self.results.hide()
40
+
41
+ lay = QVBoxLayout(self)
42
+ lay.setContentsMargins(0, 0, 0, 0)
43
+ lay.setSpacing(6)
44
+ lay.addWidget(self.search)
45
+ lay.addWidget(self.results)
46
+
47
+ def _open_selected(self, item: QListWidgetItem):
48
+ date_str = item.data(Qt.ItemDataRole.UserRole)
49
+ if date_str:
50
+ self.openDateRequested.emit(date_str)
51
+
52
+ def _search(self, text: str):
53
+ """
54
+ Search for the supplied text in the database.
55
+ For all rows found, populate the results widget with a clickable preview.
56
+ """
57
+ q = text.strip()
58
+ if not q:
59
+ self.results.clear()
60
+ self.results.hide()
61
+ return
62
+
63
+ try:
64
+ rows: Iterable[Row] = self._db.search_entries(q)
65
+ except Exception:
66
+ # be quiet on DB errors here; caller can surface if desired
67
+ rows = []
68
+
69
+ self._populate_results(q, rows)
70
+
71
+ def _populate_results(self, query: str, rows: Iterable[Row]):
72
+ self.results.clear()
73
+ rows = list(rows)
74
+ if not rows:
75
+ self.results.hide()
76
+ return
77
+
78
+ self.results.show()
79
+
80
+ for date_str, content in rows:
81
+ # Build an HTML fragment around the match and whether to show ellipses
82
+ frag_html, left_ell, right_ell = self._make_html_snippet(
83
+ content, query, radius=30, maxlen=90
84
+ )
85
+
86
+ # ---- Per-item widget: date on top, preview row below (with ellipses) ----
87
+ container = QWidget()
88
+ outer = QVBoxLayout(container)
89
+ outer.setContentsMargins(8, 6, 8, 6)
90
+ outer.setSpacing(2)
91
+
92
+ # Date label (plain text)
93
+ date_lbl = QLabel(date_str)
94
+ date_lbl.setTextFormat(Qt.TextFormat.PlainText)
95
+ date_f = date_lbl.font()
96
+ date_f.setPointSizeF(date_f.pointSizeF() - 1)
97
+ date_lbl.setFont(date_f)
98
+ date_lbl.setStyleSheet("color:#666;")
99
+ outer.addWidget(date_lbl)
100
+
101
+ # Preview row with optional ellipses
102
+ row = QWidget()
103
+ h = QHBoxLayout(row)
104
+ h.setContentsMargins(0, 0, 0, 0)
105
+ h.setSpacing(4)
106
+
107
+ if left_ell:
108
+ left = QLabel("…")
109
+ left.setStyleSheet("color:#888;")
110
+ h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
111
+
112
+ preview = QLabel()
113
+ preview.setTextFormat(Qt.TextFormat.RichText)
114
+ preview.setWordWrap(True)
115
+ preview.setOpenExternalLinks(True)
116
+ preview.setText(
117
+ frag_html
118
+ if frag_html
119
+ else "<span style='color:#888'>(no preview)</span>"
120
+ )
121
+ h.addWidget(preview, 1)
122
+
123
+ if right_ell:
124
+ right = QLabel("…")
125
+ right.setStyleSheet("color:#888;")
126
+ h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
127
+
128
+ outer.addWidget(row)
129
+
130
+ # ---- Add to list ----
131
+ item = QListWidgetItem()
132
+ item.setData(Qt.ItemDataRole.UserRole, date_str)
133
+ item.setSizeHint(container.sizeHint())
134
+
135
+ self.results.addItem(item)
136
+ self.results.setItemWidget(item, container)
137
+
138
+ # --- Snippet/highlight helpers -----------------------------------------
139
+ def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
140
+ doc = QTextDocument()
141
+ doc.setHtml(html_src)
142
+ plain = doc.toPlainText()
143
+ if not plain:
144
+ return "", False, False
145
+
146
+ tokens = [t for t in re.split(r"\s+", query.strip()) if t]
147
+ L = len(plain)
148
+
149
+ # Find first occurrence (phrase first, then earliest token)
150
+ idx, mlen = -1, 0
151
+ if tokens:
152
+ lower = plain.lower()
153
+ phrase = " ".join(tokens).lower()
154
+ j = lower.find(phrase)
155
+ if j >= 0:
156
+ idx, mlen = j, len(phrase)
157
+ else:
158
+ for t in tokens:
159
+ tj = lower.find(t.lower())
160
+ if tj >= 0 and (idx < 0 or tj < idx):
161
+ idx, mlen = tj, len(t)
162
+ # Compute window
163
+ if idx < 0:
164
+ start, end = 0, min(L, maxlen)
165
+ else:
166
+ start = max(0, min(idx - radius, max(0, L - maxlen)))
167
+ end = min(L, max(idx + mlen + radius, start + maxlen))
168
+
169
+ # Bold all token matches that fall inside [start, end)
170
+ if tokens:
171
+ lower = plain.lower()
172
+ fmt = QTextCharFormat()
173
+ fmt.setFontWeight(QFont.Weight.Bold)
174
+ for t in tokens:
175
+ t_low = t.lower()
176
+ pos = start
177
+ while True:
178
+ k = lower.find(t_low, pos)
179
+ if k == -1 or k >= end:
180
+ break
181
+ c = QTextCursor(doc)
182
+ c.setPosition(k)
183
+ c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
184
+ c.mergeCharFormat(fmt)
185
+ pos = k + len(t)
186
+
187
+ # Select the window and export as HTML fragment
188
+ c = QTextCursor(doc)
189
+ c.setPosition(start)
190
+ c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
191
+ fragment_html = (
192
+ c.selection().toHtml()
193
+ ) # preserves original styles + our bolding
194
+
195
+ return fragment_html, start > 0, end < L
bouquin/settings.py CHANGED
@@ -21,9 +21,11 @@ def get_settings() -> QSettings:
21
21
  def load_db_config() -> DBConfig:
22
22
  s = get_settings()
23
23
  path = Path(s.value("db/path", str(default_db_path())))
24
- return DBConfig(path=path, key="")
24
+ key = s.value("db/key", "")
25
+ return DBConfig(path=path, key=key)
25
26
 
26
27
 
27
28
  def save_db_config(cfg: DBConfig) -> None:
28
29
  s = get_settings()
29
30
  s.setValue("db/path", str(cfg.path))
31
+ s.setValue("db/key", str(cfg.key))
@@ -3,8 +3,12 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from PySide6.QtWidgets import (
6
+ QCheckBox,
6
7
  QDialog,
7
8
  QFormLayout,
9
+ QFrame,
10
+ QGroupBox,
11
+ QLabel,
8
12
  QHBoxLayout,
9
13
  QVBoxLayout,
10
14
  QWidget,
@@ -15,9 +19,12 @@ from PySide6.QtWidgets import (
15
19
  QSizePolicy,
16
20
  QMessageBox,
17
21
  )
22
+ from PySide6.QtCore import Qt, Slot
23
+ from PySide6.QtGui import QPalette
24
+
18
25
 
19
26
  from .db import DBConfig, DBManager
20
- from .settings import save_db_config
27
+ from .settings import load_db_config, save_db_config
21
28
  from .key_prompt import KeyPrompt
22
29
 
23
30
 
@@ -27,10 +34,11 @@ class SettingsDialog(QDialog):
27
34
  self.setWindowTitle("Settings")
28
35
  self._cfg = DBConfig(path=cfg.path, key="")
29
36
  self._db = db
37
+ self.key = ""
30
38
 
31
39
  form = QFormLayout()
32
40
  form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
33
- self.setMinimumWidth(520)
41
+ self.setMinimumWidth(560)
34
42
  self.setSizeGripEnabled(True)
35
43
 
36
44
  self.path_edit = QLineEdit(str(self._cfg.path))
@@ -47,18 +55,65 @@ class SettingsDialog(QDialog):
47
55
  h.setStretch(1, 0)
48
56
  form.addRow("Database path", path_row)
49
57
 
58
+ # Encryption settings
59
+ enc_group = QGroupBox("Encryption")
60
+ enc = QVBoxLayout(enc_group)
61
+ enc.setContentsMargins(12, 8, 12, 12)
62
+ enc.setSpacing(6)
63
+
64
+ # Checkbox to remember key
65
+ self.save_key_btn = QCheckBox("Remember key")
66
+ current_settings = load_db_config()
67
+ if current_settings.key:
68
+ self.save_key_btn.setChecked(True)
69
+ else:
70
+ self.save_key_btn.setChecked(False)
71
+ self.save_key_btn.setCursor(Qt.PointingHandCursor)
72
+ self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
73
+ enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
74
+
75
+ # Explanation for remembering key
76
+ self.save_key_label = QLabel(
77
+ "If you don't want to be prompted for your encryption key, check this to remember it. "
78
+ "WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
79
+ )
80
+ self.save_key_label.setWordWrap(True)
81
+ self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
82
+ # make it look secondary
83
+ pal = self.save_key_label.palette()
84
+ pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
85
+ self.save_key_label.setPalette(pal)
86
+
87
+ exp_row = QHBoxLayout()
88
+ exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
89
+ exp_row.addWidget(self.save_key_label)
90
+ enc.addLayout(exp_row)
91
+
92
+ line = QFrame()
93
+ line.setFrameShape(QFrame.HLine)
94
+ line.setFrameShadow(QFrame.Sunken)
95
+ enc.addWidget(line)
96
+
50
97
  # Change key button
51
98
  self.rekey_btn = QPushButton("Change key")
99
+ self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
52
100
  self.rekey_btn.clicked.connect(self._change_key)
101
+ enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
102
+
103
+ # Put the group into the form so it spans the full width nicely
104
+ form.addRow(enc_group)
53
105
 
106
+ # Buttons
54
107
  bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
55
108
  bb.accepted.connect(self._save)
56
109
  bb.rejected.connect(self.reject)
57
110
 
111
+ # Root layout (adjust margins/spacing a bit)
58
112
  v = QVBoxLayout(self)
113
+ v.setContentsMargins(12, 12, 12, 12)
114
+ v.setSpacing(10)
59
115
  v.addLayout(form)
60
- v.addWidget(self.rekey_btn)
61
- v.addWidget(bb)
116
+ v.addWidget(bb, 0, Qt.AlignRight)
62
117
 
63
118
  def _browse(self):
64
119
  p, _ = QFileDialog.getSaveFileName(
@@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
71
126
  self.path_edit.setText(p)
72
127
 
73
128
  def _save(self):
74
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
129
+ self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
75
130
  save_db_config(self._cfg)
76
131
  self.accept()
77
132
 
78
133
  def _change_key(self):
79
- p1 = KeyPrompt(self, title="Change key", message="Enter new key")
134
+ p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
80
135
  if p1.exec() != QDialog.Accepted:
81
136
  return
82
137
  new_key = p1.key()
83
- p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
138
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
84
139
  if p2.exec() != QDialog.Accepted:
85
140
  return
86
141
  if new_key != p2.key():
@@ -91,10 +146,24 @@ class SettingsDialog(QDialog):
91
146
  return
92
147
  try:
93
148
  self._db.rekey(new_key)
94
- QMessageBox.information(self, "Key changed", "The database key was updated.")
149
+ QMessageBox.information(
150
+ self, "Key changed", "The notebook was re-encrypted with the new key!"
151
+ )
95
152
  except Exception as e:
96
153
  QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
97
154
 
155
+ @Slot(bool)
156
+ def save_key_btn_clicked(self, checked: bool):
157
+ if checked:
158
+ p1 = KeyPrompt(
159
+ self, title="Enter your key", message="Enter the encryption key"
160
+ )
161
+ if p1.exec() != QDialog.Accepted:
162
+ return
163
+ self.key = p1.key()
164
+ self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
165
+ save_db_config(self._cfg)
166
+
98
167
  @property
99
168
  def config(self) -> DBConfig:
100
169
  return self._cfg