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/db.py +117 -2
- bouquin/editor.py +248 -0
- bouquin/key_prompt.py +2 -2
- bouquin/main.py +2 -1
- bouquin/main_window.py +188 -27
- bouquin/search.py +195 -0
- bouquin/settings.py +3 -1
- bouquin/settings_dialog.py +77 -8
- bouquin/toolbar.py +148 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/METADATA +13 -13
- bouquin-0.1.3.dist-info/RECORD +16 -0
- bouquin/highlighter.py +0 -112
- bouquin-0.1.1.dist-info/RECORD +0 -14
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/LICENSE +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/WHEEL +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/entry_points.txt +0 -0
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
|
|
6
|
-
from PySide6.
|
|
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
|
-
|
|
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 .
|
|
29
|
+
from .editor import Editor
|
|
21
30
|
from .key_prompt import KeyPrompt
|
|
22
|
-
from .
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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("
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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))
|
bouquin/settings_dialog.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|