bouquin 0.1.10__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.
bouquin/main_window.py ADDED
@@ -0,0 +1,904 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import os
5
+ import sys
6
+ import re
7
+
8
+ from pathlib import Path
9
+ from PySide6.QtCore import (
10
+ QDate,
11
+ QTimer,
12
+ Qt,
13
+ QSettings,
14
+ Slot,
15
+ QUrl,
16
+ QEvent,
17
+ QSignalBlocker,
18
+ )
19
+ from PySide6.QtGui import (
20
+ QAction,
21
+ QBrush,
22
+ QColor,
23
+ QCursor,
24
+ QDesktopServices,
25
+ QFont,
26
+ QGuiApplication,
27
+ QPalette,
28
+ QTextCharFormat,
29
+ QTextListFormat,
30
+ )
31
+ from PySide6.QtWidgets import (
32
+ QApplication,
33
+ QCalendarWidget,
34
+ QDialog,
35
+ QFileDialog,
36
+ QMainWindow,
37
+ QMessageBox,
38
+ QSizePolicy,
39
+ QSplitter,
40
+ QVBoxLayout,
41
+ QWidget,
42
+ )
43
+
44
+ from .db import DBManager
45
+ from .editor import Editor
46
+ from .history_dialog import HistoryDialog
47
+ from .key_prompt import KeyPrompt
48
+ from .lock_overlay import LockOverlay
49
+ from .save_dialog import SaveDialog
50
+ from .search import Search
51
+ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
52
+ from .settings_dialog import SettingsDialog
53
+ from .toolbar import ToolBar
54
+ from .theme import Theme, ThemeManager
55
+
56
+
57
+ class MainWindow(QMainWindow):
58
+ def __init__(self, themes: ThemeManager, *args, **kwargs):
59
+ super().__init__(*args, **kwargs)
60
+ self.setWindowTitle(APP_NAME)
61
+ self.setMinimumSize(1000, 650)
62
+
63
+ self.themes = themes # Store the themes manager
64
+
65
+ self.cfg = load_db_config()
66
+ if not os.path.exists(self.cfg.path):
67
+ # Fresh database/first time use, so guide the user re: setting a key
68
+ first_time = True
69
+ else:
70
+ first_time = False
71
+
72
+ # Prompt for the key unless it is found in config
73
+ if not self.cfg.key:
74
+ if not self._prompt_for_key_until_valid(first_time):
75
+ sys.exit(1)
76
+ else:
77
+ self._try_connect()
78
+
79
+ # ---- UI: Left fixed panel (calendar) + right editor -----------------
80
+ self.calendar = QCalendarWidget()
81
+ self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
82
+ self.calendar.setGridVisible(True)
83
+ self.calendar.selectionChanged.connect(self._on_date_changed)
84
+
85
+ self.search = Search(self.db)
86
+ self.search.openDateRequested.connect(self._load_selected_date)
87
+ self.search.resultDatesChanged.connect(self._on_search_dates_changed)
88
+
89
+ # Lock the calendar to the left panel at the top to stop it stretching
90
+ # when the main window is resized.
91
+ left_panel = QWidget()
92
+ left_layout = QVBoxLayout(left_panel)
93
+ left_layout.setContentsMargins(8, 8, 8, 8)
94
+ left_layout.addWidget(self.calendar)
95
+ left_layout.addWidget(self.search)
96
+ left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
97
+
98
+ # This is the note-taking editor
99
+ self.editor = Editor(self.themes)
100
+
101
+ # Toolbar for controlling styling
102
+ self.toolBar = ToolBar()
103
+ self.addToolBar(self.toolBar)
104
+ # Wire toolbar intents to editor methods
105
+ self.toolBar.boldRequested.connect(self.editor.apply_weight)
106
+ self.toolBar.italicRequested.connect(self.editor.apply_italic)
107
+ self.toolBar.underlineRequested.connect(self.editor.apply_underline)
108
+ self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
109
+ self.toolBar.codeRequested.connect(self.editor.apply_code)
110
+ self.toolBar.headingRequested.connect(self.editor.apply_heading)
111
+ self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
112
+ self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
113
+ self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
114
+ self.toolBar.alignRequested.connect(self.editor.setAlignment)
115
+ self.toolBar.historyRequested.connect(self._open_history)
116
+ self.toolBar.insertImageRequested.connect(self._on_insert_image)
117
+
118
+ self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
119
+ self.editor.cursorPositionChanged.connect(self._sync_toolbar)
120
+
121
+ split = QSplitter()
122
+ split.addWidget(left_panel)
123
+ split.addWidget(self.editor)
124
+ split.setStretchFactor(1, 1) # editor grows
125
+
126
+ container = QWidget()
127
+ lay = QVBoxLayout(container)
128
+ lay.addWidget(split)
129
+ self.setCentralWidget(container)
130
+
131
+ # Idle lock setup
132
+ self._idle_timer = QTimer(self)
133
+ self._idle_timer.setSingleShot(True)
134
+ self._idle_timer.timeout.connect(self._enter_lock)
135
+ self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
136
+ self._idle_timer.start()
137
+
138
+ # full-window overlay that sits on top of the central widget
139
+ self._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked)
140
+ self.centralWidget().installEventFilter(self._lock_overlay)
141
+
142
+ self._locked = False
143
+
144
+ # reset idle timer on any key press anywhere in the app
145
+ from PySide6.QtWidgets import QApplication
146
+
147
+ QApplication.instance().installEventFilter(self)
148
+
149
+ # Status bar for feedback
150
+ self.statusBar().showMessage("Ready", 800)
151
+
152
+ # Menu bar (File)
153
+ mb = self.menuBar()
154
+ file_menu = mb.addMenu("&File")
155
+ act_save = QAction("&Save a version", self)
156
+ act_save.setShortcut("Ctrl+S")
157
+ act_save.triggered.connect(lambda: self._save_current(explicit=True))
158
+ file_menu.addAction(act_save)
159
+ act_history = QAction("History", self)
160
+ act_history.setShortcut("Ctrl+H")
161
+ act_history.setShortcutContext(Qt.ApplicationShortcut)
162
+ act_history.triggered.connect(self._open_history)
163
+ file_menu.addAction(act_history)
164
+ act_settings = QAction("Settin&gs", self)
165
+ act_settings.setShortcut("Ctrl+G")
166
+ act_settings.triggered.connect(self._open_settings)
167
+ file_menu.addAction(act_settings)
168
+ act_export = QAction("&Export", self)
169
+ act_export.setShortcut("Ctrl+E")
170
+ act_export.triggered.connect(self._export)
171
+ file_menu.addAction(act_export)
172
+ act_backup = QAction("&Backup", self)
173
+ act_backup.setShortcut("Ctrl+Shift+B")
174
+ act_backup.triggered.connect(self._backup)
175
+ file_menu.addAction(act_backup)
176
+ file_menu.addSeparator()
177
+ act_quit = QAction("&Quit", self)
178
+ act_quit.setShortcut("Ctrl+Q")
179
+ act_quit.triggered.connect(self.close)
180
+ file_menu.addAction(act_quit)
181
+
182
+ # Navigate menu with next/previous/today
183
+ nav_menu = mb.addMenu("&Navigate")
184
+ act_prev = QAction("Previous Day", self)
185
+ act_prev.setShortcut("Ctrl+Shift+P")
186
+ act_prev.setShortcutContext(Qt.ApplicationShortcut)
187
+ act_prev.triggered.connect(lambda: self._adjust_day(-1))
188
+ nav_menu.addAction(act_prev)
189
+ self.addAction(act_prev)
190
+
191
+ act_next = QAction("Next Day", self)
192
+ act_next.setShortcut("Ctrl+Shift+N")
193
+ act_next.setShortcutContext(Qt.ApplicationShortcut)
194
+ act_next.triggered.connect(lambda: self._adjust_day(1))
195
+ nav_menu.addAction(act_next)
196
+ self.addAction(act_next)
197
+
198
+ act_today = QAction("Today", self)
199
+ act_today.setShortcut("Ctrl+Shift+T")
200
+ act_today.setShortcutContext(Qt.ApplicationShortcut)
201
+ act_today.triggered.connect(self._adjust_today)
202
+ nav_menu.addAction(act_today)
203
+ self.addAction(act_today)
204
+
205
+ # Help menu with drop-down
206
+ help_menu = mb.addMenu("&Help")
207
+ act_docs = QAction("Documentation", self)
208
+ act_docs.setShortcut("Ctrl+D")
209
+ act_docs.setShortcutContext(Qt.ApplicationShortcut)
210
+ act_docs.triggered.connect(self._open_docs)
211
+ help_menu.addAction(act_docs)
212
+ self.addAction(act_docs)
213
+ act_bugs = QAction("Report a bug", self)
214
+ act_bugs.setShortcut("Ctrl+R")
215
+ act_bugs.setShortcutContext(Qt.ApplicationShortcut)
216
+ act_bugs.triggered.connect(self._open_bugs)
217
+ help_menu.addAction(act_bugs)
218
+ self.addAction(act_bugs)
219
+
220
+ # Autosave
221
+ self._dirty = False
222
+ self._save_timer = QTimer(self)
223
+ self._save_timer.setSingleShot(True)
224
+ self._save_timer.timeout.connect(self._save_current)
225
+ self.editor.textChanged.connect(self._on_text_changed)
226
+
227
+ # First load + mark dates in calendar with content
228
+ if not self._load_yesterday_todos():
229
+ self._load_selected_date()
230
+ self._refresh_calendar_marks()
231
+
232
+ # Restore window position from settings
233
+ self.settings = QSettings(APP_ORG, APP_NAME)
234
+ self._restore_window_position()
235
+
236
+ self._apply_link_css() # Apply link color on startup
237
+ # re-apply all runtime color tweaks when theme changes
238
+ self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
239
+ self.themes.themeChanged.connect(self._apply_calendar_theme)
240
+ self._apply_calendar_text_colors()
241
+ self._apply_calendar_theme(self.themes.current())
242
+
243
+ # apply once on startup so links / calendar colors are set immediately
244
+ self._retheme_overrides()
245
+
246
+ def _try_connect(self) -> bool:
247
+ """
248
+ Try to connect to the database.
249
+ """
250
+ try:
251
+ self.db = DBManager(self.cfg)
252
+ ok = self.db.connect()
253
+ except Exception as e:
254
+ if str(e) == "file is not a database":
255
+ error = "The key is probably incorrect."
256
+ else:
257
+ error = str(e)
258
+ QMessageBox.critical(self, "Database Error", error)
259
+ return False
260
+ return ok
261
+
262
+ def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
263
+ """
264
+ Prompt for the SQLCipher key.
265
+ """
266
+ if first_time:
267
+ title = "Set an encryption key"
268
+ message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
269
+ else:
270
+ title = "Unlock encrypted notebook"
271
+ message = "Enter your key to unlock the notebook"
272
+ while True:
273
+ dlg = KeyPrompt(self, title, message)
274
+ if dlg.exec() != QDialog.Accepted:
275
+ return False
276
+ self.cfg.key = dlg.key()
277
+ if self._try_connect():
278
+ return True
279
+
280
+ def _retheme_overrides(self):
281
+ if hasattr(self, "_lock_overlay"):
282
+ self._lock_overlay._apply_overlay_style()
283
+ self._apply_calendar_text_colors()
284
+ self._apply_link_css() # Reapply link styles based on the current theme
285
+ self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
286
+ self.calendar.update()
287
+ self.editor.viewport().update()
288
+
289
+ def _apply_link_css(self):
290
+ if self.themes and self.themes.current() == Theme.DARK:
291
+ anchor = Theme.ORANGE_ANCHOR.value
292
+ visited = Theme.ORANGE_ANCHOR_VISITED.value
293
+ css = f"""
294
+ a {{ color: {anchor}; text-decoration: underline; }}
295
+ a:visited {{ color: {visited}; }}
296
+ """
297
+ else:
298
+ css = "" # Default to no custom styling for links (system or light theme)
299
+
300
+ try:
301
+ # Apply to the editor (QTextEdit or any other relevant widgets)
302
+ self.editor.document().setDefaultStyleSheet(css)
303
+ except Exception:
304
+ pass
305
+
306
+ try:
307
+ self.search.document().setDefaultStyleSheet(css)
308
+ except Exception:
309
+ pass
310
+
311
+ def _apply_calendar_theme(self, theme: Theme):
312
+ """Use orange accents on the calendar in dark mode only."""
313
+ app_pal = QApplication.instance().palette()
314
+
315
+ if theme == Theme.DARK:
316
+ highlight = QColor(Theme.ORANGE_ANCHOR.value)
317
+ black = QColor(0, 0, 0)
318
+
319
+ highlight_css = Theme.ORANGE_ANCHOR.value
320
+
321
+ # Per-widget palette: selection color inside the date grid
322
+ pal = self.calendar.palette()
323
+ pal.setColor(QPalette.Highlight, highlight)
324
+ pal.setColor(QPalette.HighlightedText, black)
325
+ self.calendar.setPalette(pal)
326
+
327
+ # Stylesheet: nav bar + selected-day background
328
+ self.calendar.setStyleSheet(
329
+ f"""
330
+ QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
331
+ QCalendarWidget QToolButton {{ color: black; }}
332
+ QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
333
+ /* Selected day color in the table view */
334
+ QCalendarWidget QTableView:enabled {{
335
+ selection-background-color: {highlight_css};
336
+ selection-color: black;
337
+ }}
338
+ /* Optional: keep weekday header readable */
339
+ QCalendarWidget QTableView QHeaderView::section {{
340
+ background: transparent;
341
+ color: palette(windowText);
342
+ }}
343
+ """
344
+ )
345
+ else:
346
+ # Back to app defaults in light/system
347
+ self.calendar.setPalette(app_pal)
348
+ self.calendar.setStyleSheet("")
349
+
350
+ # Keep weekend text color in sync with the current palette
351
+ self._apply_calendar_text_colors()
352
+ self.calendar.update()
353
+
354
+ def _apply_calendar_text_colors(self):
355
+ pal = self.palette()
356
+ txt = pal.windowText().color()
357
+ fmt = QTextCharFormat()
358
+ fmt.setForeground(txt)
359
+ # Use normal text color for weekends
360
+ self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
361
+ self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
362
+
363
+ def _on_search_dates_changed(self, date_strs: list[str]):
364
+ dates = set()
365
+ for ds in date_strs or []:
366
+ qd = QDate.fromString(ds, "yyyy-MM-dd")
367
+ if qd.isValid():
368
+ dates.add(qd)
369
+ self._apply_search_highlights(dates)
370
+
371
+ def _apply_search_highlights(self, dates: set):
372
+ pal = self.palette()
373
+ base = pal.base().color()
374
+ hi = pal.highlight().color()
375
+ # Blend highlight with base so it looks soft in both modes
376
+ blend = QColor(
377
+ (2 * hi.red() + base.red()) // 3,
378
+ (2 * hi.green() + base.green()) // 3,
379
+ (2 * hi.blue() + base.blue()) // 3,
380
+ )
381
+ yellow = QBrush(blend)
382
+ old = getattr(self, "_search_highlighted_dates", set())
383
+
384
+ for d in old - dates: # clear removed
385
+ fmt = self.calendar.dateTextFormat(d)
386
+ fmt.setBackground(Qt.transparent)
387
+ self.calendar.setDateTextFormat(d, fmt)
388
+
389
+ for d in dates: # apply new/current
390
+ fmt = self.calendar.dateTextFormat(d)
391
+ fmt.setBackground(yellow)
392
+ self.calendar.setDateTextFormat(d, fmt)
393
+
394
+ self._search_highlighted_dates = dates
395
+
396
+ def _refresh_calendar_marks(self):
397
+ """Make days with entries bold, but keep any search highlight backgrounds."""
398
+ for d in getattr(self, "_marked_dates", set()):
399
+ fmt = self.calendar.dateTextFormat(d)
400
+ fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
401
+ self.calendar.setDateTextFormat(d, fmt)
402
+ self._marked_dates = set()
403
+ try:
404
+ for date_iso in self.db.dates_with_content():
405
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
406
+ if qd.isValid():
407
+ fmt = self.calendar.dateTextFormat(qd)
408
+ fmt.setFontWeight(QFont.Weight.Bold) # add bold only
409
+ self.calendar.setDateTextFormat(qd, fmt)
410
+ self._marked_dates.add(qd)
411
+ except Exception:
412
+ pass
413
+
414
+ # --- UI handlers ---------------------------------------------------------
415
+
416
+ def _sync_toolbar(self):
417
+ fmt = self.editor.currentCharFormat()
418
+ c = self.editor.textCursor()
419
+ bf = c.blockFormat()
420
+
421
+ # Block signals so setChecked() doesn't re-trigger actions
422
+ QSignalBlocker(self.toolBar.actBold)
423
+ QSignalBlocker(self.toolBar.actItalic)
424
+ QSignalBlocker(self.toolBar.actUnderline)
425
+ QSignalBlocker(self.toolBar.actStrike)
426
+
427
+ self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
428
+ self.toolBar.actItalic.setChecked(fmt.fontItalic())
429
+ self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
430
+ self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
431
+
432
+ # Headings: decide which to check by current point size
433
+ def _approx(a, b, eps=0.5): # small float tolerance
434
+ return abs(float(a) - float(b)) <= eps
435
+
436
+ cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF()
437
+
438
+ bH1 = _approx(cur_size, 24)
439
+ bH2 = _approx(cur_size, 18)
440
+ bH3 = _approx(cur_size, 14)
441
+
442
+ QSignalBlocker(self.toolBar.actH1)
443
+ QSignalBlocker(self.toolBar.actH2)
444
+ QSignalBlocker(self.toolBar.actH3)
445
+ QSignalBlocker(self.toolBar.actNormal)
446
+
447
+ self.toolBar.actH1.setChecked(bH1)
448
+ self.toolBar.actH2.setChecked(bH2)
449
+ self.toolBar.actH3.setChecked(bH3)
450
+ self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3))
451
+
452
+ # Lists
453
+ lst = c.currentList()
454
+ bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
455
+ numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
456
+ QSignalBlocker(self.toolBar.actBullets)
457
+ QSignalBlocker(self.toolBar.actNumbers)
458
+ self.toolBar.actBullets.setChecked(bool(bullets_on))
459
+ self.toolBar.actNumbers.setChecked(bool(numbers_on))
460
+
461
+ # Alignment
462
+ align = bf.alignment() & Qt.AlignHorizontal_Mask
463
+ QSignalBlocker(self.toolBar.actAlignL)
464
+ self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
465
+ QSignalBlocker(self.toolBar.actAlignC)
466
+ self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
467
+ QSignalBlocker(self.toolBar.actAlignR)
468
+ self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
469
+
470
+ def _current_date_iso(self) -> str:
471
+ d = self.calendar.selectedDate()
472
+ return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
473
+
474
+ def _load_selected_date(self, date_iso=False, extra_data=False):
475
+ if not date_iso:
476
+ date_iso = self._current_date_iso()
477
+ try:
478
+ text = self.db.get_entry(date_iso)
479
+ if extra_data:
480
+ # Wrap extra_data in a <p> tag for HTML rendering
481
+ extra_data_html = f"<p>{extra_data}</p>"
482
+
483
+ # Inject the extra_data before the closing </body></html>
484
+ modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
485
+ text = modified
486
+ self.editor.setHtml(text)
487
+ self._dirty = True
488
+ self._save_date(date_iso, True)
489
+
490
+ print("end")
491
+ except Exception as e:
492
+ QMessageBox.critical(self, "Read Error", str(e))
493
+ return
494
+
495
+ self.editor.blockSignals(True)
496
+ self.editor.setHtml(text)
497
+ self.editor.blockSignals(False)
498
+
499
+ self._dirty = False
500
+ # track which date the editor currently represents
501
+ self._active_date_iso = date_iso
502
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
503
+ self.calendar.setSelectedDate(qd)
504
+
505
+ def _on_text_changed(self):
506
+ self._dirty = True
507
+ self._save_timer.start(5000) # autosave after idle
508
+
509
+ def _adjust_day(self, delta: int):
510
+ """Move selection by delta days (negative for previous)."""
511
+ d = self.calendar.selectedDate().addDays(delta)
512
+ self.calendar.setSelectedDate(d)
513
+
514
+ def _adjust_today(self):
515
+ """Jump to today."""
516
+ today = QDate.currentDate()
517
+ self.calendar.setSelectedDate(today)
518
+
519
+ def _load_yesterday_todos(self):
520
+ try:
521
+ if not self.cfg.move_todos:
522
+ return
523
+ yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
524
+ text = self.db.get_entry(yesterday_str)
525
+ unchecked_items = []
526
+
527
+ # Regex to match the unchecked checkboxes and their associated text
528
+ checkbox_pattern = re.compile(
529
+ r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL
530
+ )
531
+
532
+ # Find unchecked items and store them
533
+ for match in checkbox_pattern.finditer(text):
534
+ checkbox = match.group(1) # Either ☐ or ☑
535
+ item_text = match.group(2).strip() # The text after the checkbox
536
+ if checkbox == "☐": # If it's an unchecked checkbox (☐)
537
+ unchecked_items.append("☐ " + item_text) # Store the unchecked item
538
+
539
+ # Remove the unchecked items from yesterday's HTML content
540
+ if unchecked_items:
541
+ # This regex will find the entire checkbox line and remove it from the HTML content
542
+ uncheckbox_pattern = re.compile(
543
+ r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
544
+ )
545
+ modified_text = re.sub(
546
+ uncheckbox_pattern, "", text
547
+ ) # Remove the checkbox lines
548
+
549
+ # Save the modified HTML back to the database
550
+ self.db.save_new_version(
551
+ yesterday_str,
552
+ modified_text,
553
+ "Unchecked checkbox items moved to next day",
554
+ )
555
+
556
+ # Join unchecked items into a formatted string
557
+ unchecked_str = "\n".join(
558
+ [f"<p>{item}</p>" for item in unchecked_items]
559
+ )
560
+
561
+ # Load the unchecked items into the current editor
562
+ self._load_selected_date(False, unchecked_str)
563
+ else:
564
+ return False
565
+
566
+ except Exception as e:
567
+ raise SystemError(e)
568
+
569
+ def _on_date_changed(self):
570
+ """
571
+ When the calendar selection changes, save the previous day's note if dirty,
572
+ so we don't lose that text, then load the newly selected day.
573
+ """
574
+ # Stop pending autosave and persist current buffer if needed
575
+ try:
576
+ self._save_timer.stop()
577
+ except Exception:
578
+ pass
579
+ prev = getattr(self, "_active_date_iso", None)
580
+ if prev and self._dirty:
581
+ self._save_date(prev, explicit=False)
582
+ # Now load the newly selected date
583
+ self._load_selected_date()
584
+
585
+ def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
586
+ """
587
+ Save editor contents into the given date. Shows status on success.
588
+ explicit=True means user invoked Save: show feedback even if nothing changed.
589
+ """
590
+ if not self._dirty and not explicit:
591
+ return
592
+ text = self.editor.to_html_with_embedded_images()
593
+ try:
594
+ self.db.save_new_version(date_iso, text, note)
595
+ except Exception as e:
596
+ QMessageBox.critical(self, "Save Error", str(e))
597
+ return
598
+ self._dirty = False
599
+ self._refresh_calendar_marks()
600
+ # Feedback in the status bar
601
+ from datetime import datetime as _dt
602
+
603
+ self.statusBar().showMessage(
604
+ f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
605
+ )
606
+
607
+ def _save_current(self, explicit: bool = False):
608
+ try:
609
+ self._save_timer.stop()
610
+ except Exception:
611
+ pass
612
+ if explicit:
613
+ # Prompt for a note
614
+ dlg = SaveDialog(self)
615
+ if dlg.exec() != QDialog.Accepted:
616
+ return
617
+ note = dlg.note_text()
618
+ else:
619
+ note = "autosave"
620
+ # Delegate to _save_date for the currently selected date
621
+ self._save_date(self._current_date_iso(), explicit, note)
622
+ try:
623
+ self._save_timer.start()
624
+ except Exception:
625
+ pass
626
+
627
+ def _open_history(self):
628
+ date_iso = self._current_date_iso()
629
+ dlg = HistoryDialog(self.db, date_iso, self)
630
+ if dlg.exec() == QDialog.Accepted:
631
+ # refresh editor + calendar (head pointer may have changed)
632
+ self._load_selected_date(date_iso)
633
+ self._refresh_calendar_marks()
634
+
635
+ def _on_insert_image(self):
636
+ # Let the user pick one or many images
637
+ paths, _ = QFileDialog.getOpenFileNames(
638
+ self,
639
+ "Insert image(s)",
640
+ "",
641
+ "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
642
+ )
643
+ if not paths:
644
+ return
645
+ self.editor.insert_images(paths) # call into the editor
646
+
647
+ # ----------- Settings handler ------------#
648
+ def _open_settings(self):
649
+ dlg = SettingsDialog(self.cfg, self.db, self)
650
+ if dlg.exec() != QDialog.Accepted:
651
+ return
652
+
653
+ new_cfg = dlg.config
654
+ old_path = self.cfg.path
655
+
656
+ # Update in-memory config from the dialog
657
+ self.cfg.path = new_cfg.path
658
+ self.cfg.key = new_cfg.key
659
+ self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
660
+ self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
661
+ self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
662
+
663
+ # Persist once
664
+ save_db_config(self.cfg)
665
+
666
+ # Apply idle setting immediately (restart the timer with new interval if it changed)
667
+ self._apply_idle_minutes(self.cfg.idle_minutes)
668
+
669
+ # If the DB path changed, reconnect
670
+ if self.cfg.path != old_path:
671
+ self.db.close()
672
+ if not self._prompt_for_key_until_valid(first_time=False):
673
+ QMessageBox.warning(
674
+ self, "Reopen failed", "Could not unlock database at new path."
675
+ )
676
+ return
677
+ self._load_selected_date()
678
+ self._refresh_calendar_marks()
679
+
680
+ # ------------ Window positioning --------------- #
681
+ def _restore_window_position(self):
682
+ geom = self.settings.value("main/geometry", None)
683
+ state = self.settings.value("main/windowState", None)
684
+ was_max = self.settings.value("main/maximized", False, type=bool)
685
+
686
+ if geom is not None:
687
+ self.restoreGeometry(geom)
688
+ if state is not None:
689
+ self.restoreState(state)
690
+ if not self._rect_on_any_screen(self.frameGeometry()):
691
+ self._move_to_cursor_screen_center()
692
+ else:
693
+ # First run: place window on the screen where the mouse cursor is.
694
+ self._move_to_cursor_screen_center()
695
+
696
+ # If it was maximized, do that AFTER the window exists in the event loop.
697
+ if was_max:
698
+ QTimer.singleShot(0, self.showMaximized)
699
+
700
+ def _rect_on_any_screen(self, rect):
701
+ for sc in QGuiApplication.screens():
702
+ if sc.availableGeometry().intersects(rect):
703
+ return True
704
+ return False
705
+
706
+ def _move_to_cursor_screen_center(self):
707
+ screen = (
708
+ QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
709
+ )
710
+ r = screen.availableGeometry()
711
+ # Center the window in that screen’s available area
712
+ self.move(r.center() - self.rect().center())
713
+
714
+ # ----------------- Export handler ----------------- #
715
+ @Slot()
716
+ def _export(self):
717
+ warning_title = "Unencrypted export"
718
+ warning_message = """
719
+ Exporting the database will be unencrypted!
720
+
721
+ Are you sure you want to continue?
722
+
723
+ If you want an encrypted backup, choose Backup instead of Export.
724
+ """
725
+ dlg = QMessageBox()
726
+ dlg.setWindowTitle(warning_title)
727
+ dlg.setText(warning_message)
728
+ dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
729
+ dlg.setIcon(QMessageBox.Warning)
730
+ dlg.show()
731
+ dlg.adjustSize()
732
+ if dlg.exec() != QMessageBox.Yes:
733
+ return False
734
+
735
+ filters = (
736
+ "Text (*.txt);;"
737
+ "JSON (*.json);;"
738
+ "CSV (*.csv);;"
739
+ "HTML (*.html);;"
740
+ "Markdown (*.md);;"
741
+ "SQL (*.sql);;"
742
+ )
743
+
744
+ start_dir = os.path.join(os.path.expanduser("~"), "Documents")
745
+ filename, selected_filter = QFileDialog.getSaveFileName(
746
+ self, "Export entries", start_dir, filters
747
+ )
748
+ if not filename:
749
+ return # user cancelled
750
+
751
+ default_ext = {
752
+ "Text (*.txt)": ".txt",
753
+ "JSON (*.json)": ".json",
754
+ "CSV (*.csv)": ".csv",
755
+ "HTML (*.html)": ".html",
756
+ "Markdown (*.md)": ".md",
757
+ "SQL (*.sql)": ".sql",
758
+ }.get(selected_filter, ".txt")
759
+
760
+ if not Path(filename).suffix:
761
+ filename += default_ext
762
+
763
+ try:
764
+ entries = self.db.get_all_entries()
765
+ if selected_filter.startswith("Text"):
766
+ self.db.export_txt(entries, filename)
767
+ elif selected_filter.startswith("JSON"):
768
+ self.db.export_json(entries, filename)
769
+ elif selected_filter.startswith("CSV"):
770
+ self.db.export_csv(entries, filename)
771
+ elif selected_filter.startswith("HTML"):
772
+ self.db.export_html(entries, filename)
773
+ elif selected_filter.startswith("Markdown"):
774
+ self.db.export_markdown(entries, filename)
775
+ elif selected_filter.startswith("SQL"):
776
+ self.db.export_sql(filename)
777
+ else:
778
+ self.db.export_by_extension(filename)
779
+
780
+ QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
781
+ except Exception as e:
782
+ QMessageBox.critical(self, "Export failed", str(e))
783
+
784
+ # ----------------- Backup handler ----------------- #
785
+ @Slot()
786
+ def _backup(self):
787
+ filters = "SQLCipher (*.db);;"
788
+
789
+ now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
790
+ start_dir = os.path.join(
791
+ os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db"
792
+ )
793
+ filename, selected_filter = QFileDialog.getSaveFileName(
794
+ self, "Backup encrypted notebook", start_dir, filters
795
+ )
796
+ if not filename:
797
+ return # user cancelled
798
+
799
+ default_ext = {
800
+ "SQLCipher (*.db)": ".db",
801
+ }.get(selected_filter, ".db")
802
+
803
+ if not Path(filename).suffix:
804
+ filename += default_ext
805
+
806
+ try:
807
+ if selected_filter.startswith("SQL"):
808
+ self.db.export_sqlcipher(filename)
809
+ QMessageBox.information(
810
+ self, "Backup complete", f"Saved to:\n{filename}"
811
+ )
812
+ except Exception as e:
813
+ QMessageBox.critical(self, "Backup failed", str(e))
814
+
815
+ # ----------------- Help handlers ----------------- #
816
+
817
+ def _open_docs(self):
818
+ url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
819
+ url = QUrl.fromUserInput(url_str)
820
+ if not QDesktopServices.openUrl(url):
821
+ QMessageBox.warning(
822
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
823
+ )
824
+
825
+ def _open_bugs(self):
826
+ url_str = "https://nr.mig5.net/forms/mig5/contact"
827
+ url = QUrl.fromUserInput(url_str)
828
+ if not QDesktopServices.openUrl(url):
829
+ QMessageBox.warning(
830
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
831
+ )
832
+
833
+ # ----------------- Idle handlers ----------------- #
834
+ def _apply_idle_minutes(self, minutes: int):
835
+ minutes = max(0, int(minutes))
836
+ if not hasattr(self, "_idle_timer"):
837
+ return
838
+ if minutes == 0:
839
+ self._idle_timer.stop()
840
+ # If you’re currently locked, unlock when user disables the timer:
841
+ if getattr(self, "_locked", False):
842
+ try:
843
+ self._locked = False
844
+ if hasattr(self, "_lock_overlay"):
845
+ self._lock_overlay.hide()
846
+ except Exception:
847
+ pass
848
+ else:
849
+ self._idle_timer.setInterval(minutes * 60 * 1000)
850
+ if not getattr(self, "_locked", False):
851
+ self._idle_timer.start()
852
+
853
+ def eventFilter(self, obj, event):
854
+ if event.type() == QEvent.KeyPress and not self._locked:
855
+ self._idle_timer.start()
856
+ return super().eventFilter(obj, event)
857
+
858
+ def _enter_lock(self):
859
+ if self._locked:
860
+ return
861
+ self._locked = True
862
+ if self.menuBar():
863
+ self.menuBar().setEnabled(False)
864
+ if self.statusBar():
865
+ self.statusBar().setEnabled(False)
866
+ tb = getattr(self, "toolBar", None)
867
+ if tb:
868
+ tb.setEnabled(False)
869
+ self._lock_overlay.show()
870
+ self._lock_overlay.raise_()
871
+
872
+ @Slot()
873
+ def _on_unlock_clicked(self):
874
+ try:
875
+ ok = self._prompt_for_key_until_valid(first_time=False)
876
+ except Exception as e:
877
+ QMessageBox.critical(self, "Unlock failed", str(e))
878
+ return
879
+ if ok:
880
+ self._locked = False
881
+ self._lock_overlay.hide()
882
+ if self.menuBar():
883
+ self.menuBar().setEnabled(True)
884
+ if self.statusBar():
885
+ self.statusBar().setEnabled(True)
886
+ tb = getattr(self, "toolBar", None)
887
+ if tb:
888
+ tb.setEnabled(True)
889
+ self._idle_timer.start()
890
+
891
+ # ----------------- Close handlers ----------------- #
892
+ def closeEvent(self, event):
893
+ try:
894
+ # Save window position
895
+ self.settings.setValue("main/geometry", self.saveGeometry())
896
+ self.settings.setValue("main/windowState", self.saveState())
897
+ self.settings.setValue("main/maximized", self.isMaximized())
898
+
899
+ # Ensure we save any last pending edits to the db
900
+ self._save_current()
901
+ self.db.close()
902
+ except Exception:
903
+ pass
904
+ super().closeEvent(event)