from __future__ import annotations import datetime import os import sys from pathlib import Path from PySide6.QtCore import ( QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent, QSignalBlocker, ) from PySide6.QtGui import ( QAction, QCursor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextListFormat, ) from PySide6.QtWidgets import ( QCalendarWidget, QDialog, QFileDialog, QLabel, QMainWindow, QMessageBox, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget, ) from .db import DBManager from .editor import Editor from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt from .save_dialog import SaveDialog from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .toolbar import ToolBar class _LockOverlay(QWidget): def __init__(self, parent: QWidget, on_unlock: callable): super().__init__(parent) self.setObjectName("LockOverlay") self.setAttribute(Qt.WA_StyledBackground, True) self.setFocusPolicy(Qt.StrongFocus) self.setGeometry(parent.rect()) self.setStyleSheet( """ #LockOverlay { background-color: #ccc; } #LockOverlay QLabel { color: #fff; font-size: 18px; } #LockOverlay QPushButton { background-color: #f2f2f2; color: #000; padding: 6px 14px; border: 1px solid #808080; border-radius: 6px; font-size: 14px; } #LockOverlay QPushButton:hover { background-color: #ffffff; } #LockOverlay QPushButton:pressed { background-color: #e6e6e6; } """ ) lay = QVBoxLayout(self) lay.addStretch(1) msg = QLabel("Locked due to inactivity") msg.setAlignment(Qt.AlignCenter) self._btn = QPushButton("Unlock") self._btn.setFixedWidth(200) self._btn.setCursor(Qt.PointingHandCursor) self._btn.setAutoDefault(True) self._btn.setDefault(True) self._btn.clicked.connect(on_unlock) lay.addWidget(msg, 0, Qt.AlignCenter) lay.addWidget(self._btn, 0, Qt.AlignCenter) lay.addStretch(1) self.hide() # start hidden # keep overlay sized with its parent def eventFilter(self, obj, event): if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show): self.setGeometry(obj.rect()) return False def showEvent(self, e): super().showEvent(e) self._btn.setFocus() class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(APP_NAME) self.setMinimumSize(1000, 650) self.cfg = load_db_config() if not os.path.exists(self.cfg.path): # Fresh database/first time use, so guide the user re: setting a key first_time = True else: first_time = False # Prompt for the key unless it is found in config if not self.cfg.key: if not self._prompt_for_key_until_valid(first_time): sys.exit(1) else: self._try_connect() # ---- UI: Left fixed panel (calendar) + right editor ----------------- self.calendar = QCalendarWidget() self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.calendar.setGridVisible(True) self.calendar.selectionChanged.connect(self._on_date_changed) self.search = Search(self.db) self.search.openDateRequested.connect(self._load_selected_date) # Lock the calendar to the left panel at the top to stop it stretching # when the main window is resized. left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) left_layout.addWidget(self.search, alignment=Qt.AlignBottom) left_layout.addStretch(1) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # This is the note-taking editor self.editor = Editor() # Toolbar for controlling styling self.toolBar = ToolBar() self.addToolBar(self.toolBar) # Wire toolbar intents to editor methods self.toolBar.boldRequested.connect(self.editor.apply_weight) self.toolBar.italicRequested.connect(self.editor.apply_italic) self.toolBar.underlineRequested.connect(self.editor.apply_underline) self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough) self.toolBar.codeRequested.connect(self.editor.apply_code) self.toolBar.headingRequested.connect(self.editor.apply_heading) self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.alignRequested.connect(self.editor.setAlignment) self.toolBar.historyRequested.connect(self._open_history) self.toolBar.insertImageRequested.connect(self._on_insert_image) self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) self.editor.cursorPositionChanged.connect(self._sync_toolbar) split = QSplitter() split.addWidget(left_panel) split.addWidget(self.editor) split.setStretchFactor(1, 1) # editor grows container = QWidget() lay = QVBoxLayout(container) lay.addWidget(split) self.setCentralWidget(container) # Idle lock setup self._idle_timer = QTimer(self) self._idle_timer.setSingleShot(True) self._idle_timer.timeout.connect(self._enter_lock) self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15)) self._idle_timer.start() # full-window overlay that sits on top of the central widget self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked) self.centralWidget().installEventFilter(self._lock_overlay) self._locked = False # reset idle timer on any key press anywhere in the app from PySide6.QtWidgets import QApplication QApplication.instance().installEventFilter(self) # Status bar for feedback self.statusBar().showMessage("Ready", 800) # Menu bar (File) mb = self.menuBar() file_menu = mb.addMenu("&File") act_save = QAction("&Save a version", self) act_save.setShortcut("Ctrl+S") act_save.triggered.connect(lambda: self._save_current(explicit=True)) file_menu.addAction(act_save) act_history = QAction("History", self) act_history.setShortcut("Ctrl+H") act_history.setShortcutContext(Qt.ApplicationShortcut) act_history.triggered.connect(self._open_history) file_menu.addAction(act_history) act_settings = QAction("Settin&gs", self) act_settings.setShortcut("Ctrl+G") act_settings.triggered.connect(self._open_settings) file_menu.addAction(act_settings) act_export = QAction("&Export", self) act_export.setShortcut("Ctrl+E") act_export.triggered.connect(self._export) file_menu.addAction(act_export) act_backup = QAction("&Backup", self) act_backup.setShortcut("Ctrl+Shift+B") act_backup.triggered.connect(self._backup) file_menu.addAction(act_backup) file_menu.addSeparator() act_quit = QAction("&Quit", self) act_quit.setShortcut("Ctrl+Q") act_quit.triggered.connect(self.close) file_menu.addAction(act_quit) # Navigate menu with next/previous/today nav_menu = mb.addMenu("&Navigate") act_prev = QAction("Previous Day", self) act_prev.setShortcut("Ctrl+Shift+P") act_prev.setShortcutContext(Qt.ApplicationShortcut) act_prev.triggered.connect(lambda: self._adjust_day(-1)) nav_menu.addAction(act_prev) self.addAction(act_prev) act_next = QAction("Next Day", self) act_next.setShortcut("Ctrl+Shift+N") act_next.setShortcutContext(Qt.ApplicationShortcut) act_next.triggered.connect(lambda: self._adjust_day(1)) nav_menu.addAction(act_next) self.addAction(act_next) act_today = QAction("Today", self) act_today.setShortcut("Ctrl+Shift+T") act_today.setShortcutContext(Qt.ApplicationShortcut) act_today.triggered.connect(self._adjust_today) nav_menu.addAction(act_today) self.addAction(act_today) # Help menu with drop-down help_menu = mb.addMenu("&Help") act_docs = QAction("Documentation", self) act_docs.setShortcut("Ctrl+D") act_docs.setShortcutContext(Qt.ApplicationShortcut) act_docs.triggered.connect(self._open_docs) help_menu.addAction(act_docs) self.addAction(act_docs) act_bugs = QAction("Report a bug", self) act_bugs.setShortcut("Ctrl+R") act_bugs.setShortcutContext(Qt.ApplicationShortcut) act_bugs.triggered.connect(self._open_bugs) help_menu.addAction(act_bugs) self.addAction(act_bugs) # Autosave self._dirty = False self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) self._save_timer.timeout.connect(self._save_current) self.editor.textChanged.connect(self._on_text_changed) # First load + mark dates in calendar with content self._load_selected_date() self._refresh_calendar_marks() # Restore window position from settings self.settings = QSettings(APP_ORG, APP_NAME) self._restore_window_position() def _try_connect(self) -> bool: """ Try to connect to the database. """ try: self.db = DBManager(self.cfg) ok = self.db.connect() except Exception as e: if str(e) == "file is not a database": error = "The key is probably incorrect." else: error = str(e) QMessageBox.critical(self, "Database Error", error) return False return ok def _prompt_for_key_until_valid(self, first_time: bool) -> bool: """ Prompt for the SQLCipher key. """ if first_time: title = "Set an encryption key" message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!" else: title = "Unlock encrypted notebook" message = "Enter your key to unlock the notebook" while True: dlg = KeyPrompt(self, title, message) if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() if self._try_connect(): return True def _refresh_calendar_marks(self): """ Sets a bold marker on the day to indicate that text exists for that day. """ fmt_bold = QTextCharFormat() fmt_bold.setFontWeight(QFont.Weight.Bold) # Clear previous marks for d in getattr(self, "_marked_dates", set()): self.calendar.setDateTextFormat(d, QTextCharFormat()) self._marked_dates = set() try: for date_iso in self.db.dates_with_content(): qd = QDate.fromString(date_iso, "yyyy-MM-dd") if qd.isValid(): self.calendar.setDateTextFormat(qd, fmt_bold) self._marked_dates.add(qd) except Exception: pass # --- UI handlers --------------------------------------------------------- def _sync_toolbar(self): fmt = self.editor.currentCharFormat() c = self.editor.textCursor() bf = c.blockFormat() # Block signals so setChecked() doesn't re-trigger actions blocker1 = QSignalBlocker(self.toolBar.actBold) blocker2 = QSignalBlocker(self.toolBar.actItalic) blocker3 = QSignalBlocker(self.toolBar.actUnderline) blocker4 = QSignalBlocker(self.toolBar.actStrike) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actItalic.setChecked(fmt.fontItalic()) self.toolBar.actUnderline.setChecked(fmt.fontUnderline()) self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) # Headings: decide which to check by current point size def _approx(a, b, eps=0.5): # small float tolerance return abs(float(a) - float(b)) <= eps cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() bH1 = _approx(cur_size, 24) bH2 = _approx(cur_size, 18) bH3 = _approx(cur_size, 14) b1 = QSignalBlocker(self.toolBar.actH1) b2 = QSignalBlocker(self.toolBar.actH2) b3 = QSignalBlocker(self.toolBar.actH3) bN = QSignalBlocker(self.toolBar.actNormal) self.toolBar.actH1.setChecked(bH1) self.toolBar.actH2.setChecked(bH2) self.toolBar.actH3.setChecked(bH3) self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) # Lists lst = c.currentList() bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal QSignalBlocker(self.toolBar.actBullets) QSignalBlocker(self.toolBar.actNumbers) self.toolBar.actBullets.setChecked(bool(bullets_on)) self.toolBar.actNumbers.setChecked(bool(numbers_on)) # Alignment align = bf.alignment() & Qt.AlignHorizontal_Mask QSignalBlocker(self.toolBar.actAlignL) self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft) QSignalBlocker(self.toolBar.actAlignC) self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter) QSignalBlocker(self.toolBar.actAlignR) self.toolBar.actAlignR.setChecked(align == Qt.AlignRight) def _current_date_iso(self) -> str: d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" def _load_selected_date(self, date_iso=False): if not date_iso: date_iso = self._current_date_iso() try: text = self.db.get_entry(date_iso) except Exception as e: QMessageBox.critical(self, "Read Error", str(e)) return self.editor.blockSignals(True) self.editor.setHtml(text) self.editor.blockSignals(False) self._dirty = False # track which date the editor currently represents self._active_date_iso = date_iso qd = QDate.fromString(date_iso, "yyyy-MM-dd") self.calendar.setSelectedDate(qd) def _on_text_changed(self): self._dirty = True self._save_timer.start(10000) # autosave after idle def _adjust_day(self, delta: int): """Move selection by delta days (negative for previous).""" d = self.calendar.selectedDate().addDays(delta) self.calendar.setSelectedDate(d) def _adjust_today(self): """Jump to today.""" today = QDate.currentDate() self.calendar.setSelectedDate(today) def _on_date_changed(self): """ When the calendar selection changes, save the previous day's note if dirty, so we don't lose that text, then load the newly selected day. """ # Stop pending autosave and persist current buffer if needed try: self._save_timer.stop() except Exception: pass prev = getattr(self, "_active_date_iso", None) if prev and self._dirty: self._save_date(prev, explicit=False) # Now load the newly selected date self._load_selected_date() def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"): """ Save editor contents into the given date. Shows status on success. explicit=True means user invoked Save: show feedback even if nothing changed. """ if not self._dirty and not explicit: return text = self.editor.to_html_with_embedded_images() try: self.db.save_new_version(date_iso, text, note) except Exception as e: QMessageBox.critical(self, "Save Error", str(e)) return self._dirty = False self._refresh_calendar_marks() # Feedback in the status bar from datetime import datetime as _dt self.statusBar().showMessage( f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000 ) def _save_current(self, explicit: bool = False): try: self._save_timer.stop() except Exception: pass if explicit: # Prompt for a note dlg = SaveDialog(self) if dlg.exec() != QDialog.Accepted: return note = dlg.note_text() else: note = "autosave" # Delegate to _save_date for the currently selected date self._save_date(self._current_date_iso(), explicit, note) try: self._save_timer.start() except Exception: pass def _open_history(self): date_iso = self._current_date_iso() dlg = HistoryDialog(self.db, date_iso, self) if dlg.exec() == QDialog.Accepted: # refresh editor + calendar (head pointer may have changed) self._load_selected_date(date_iso) self._refresh_calendar_marks() def _on_insert_image(self): # Let the user pick one or many images paths, _ = QFileDialog.getOpenFileNames( self, "Insert image(s)", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)", ) if not paths: return self.editor.insert_images(paths) # call into the editor # ----------- Settings handler ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) if dlg.exec() != QDialog.Accepted: return new_cfg = dlg.config old_path = self.cfg.path # Update in-memory config from the dialog self.cfg.path = new_cfg.path self.cfg.key = new_cfg.key self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) # Persist once save_db_config(self.cfg) # Apply idle setting immediately (restart the timer with new interval if it changed) self._apply_idle_minutes(self.cfg.idle_minutes) # If the DB path changed, reconnect if self.cfg.path != old_path: self.db.close() if not self._prompt_for_key_until_valid(first_time=False): QMessageBox.warning( self, "Reopen failed", "Could not unlock database at new path." ) return self._load_selected_date() self._refresh_calendar_marks() # ------------ Window positioning --------------- # def _restore_window_position(self): geom = self.settings.value("main/geometry", None) state = self.settings.value("main/windowState", None) was_max = self.settings.value("main/maximized", False, type=bool) if geom is not None: self.restoreGeometry(geom) if state is not None: self.restoreState(state) if not self._rect_on_any_screen(self.frameGeometry()): self._move_to_cursor_screen_center() else: # First run: place window on the screen where the mouse cursor is. self._move_to_cursor_screen_center() # If it was maximized, do that AFTER the window exists in the event loop. if was_max: QTimer.singleShot(0, self.showMaximized) def _rect_on_any_screen(self, rect): for sc in QGuiApplication.screens(): if sc.availableGeometry().intersects(rect): return True return False def _move_to_cursor_screen_center(self): screen = ( QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() ) r = screen.availableGeometry() # Center the window in that screen’s available area self.move(r.center() - self.rect().center()) # ----------------- Export handler ----------------- # @Slot() def _export(self): warning_title = "Unencrypted export" warning_message = """ Exporting the database will be unencrypted! Are you sure you want to continue? If you want an encrypted backup, choose Backup instead of Export. """ dlg = QMessageBox() dlg.setWindowTitle(warning_title) dlg.setText(warning_message) dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) dlg.setIcon(QMessageBox.Warning) dlg.show() dlg.adjustSize() if dlg.exec() != QMessageBox.Yes: return False filters = ( "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;" "SQL (*.sql);;" ) start_dir = os.path.join(os.path.expanduser("~"), "Documents") filename, selected_filter = QFileDialog.getSaveFileName( self, "Export entries", start_dir, filters ) if not filename: return # user cancelled default_ext = { "Text (*.txt)": ".txt", "JSON (*.json)": ".json", "CSV (*.csv)": ".csv", "HTML (*.html)": ".html", "SQL (*.sql)": ".sql", }.get(selected_filter, ".txt") if not Path(filename).suffix: filename += default_ext try: entries = self.db.get_all_entries() if selected_filter.startswith("Text"): self.db.export_txt(entries, filename) elif selected_filter.startswith("JSON"): self.db.export_json(entries, filename) elif selected_filter.startswith("CSV"): self.db.export_csv(entries, filename) elif selected_filter.startswith("HTML"): self.db.export_html(entries, filename) elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: self.db.export_by_extension(entries, filename) QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}") except Exception as e: QMessageBox.critical(self, "Export failed", str(e)) # ----------------- Backup handler ----------------- # @Slot() def _backup(self): filters = "SQLCipher (*.db);;" now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") start_dir = os.path.join( os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db" ) filename, selected_filter = QFileDialog.getSaveFileName( self, "Backup encrypted notebook", start_dir, filters ) if not filename: return # user cancelled default_ext = { "SQLCipher (*.db)": ".db", }.get(selected_filter, ".db") if not Path(filename).suffix: filename += default_ext try: if selected_filter.startswith("SQL"): self.db.export_sqlcipher(filename) QMessageBox.information( self, "Backup complete", f"Saved to:\n{filename}" ) except Exception as e: QMessageBox.critical(self, "Backup failed", str(e)) # ----------------- Help handlers ----------------- # def _open_docs(self): url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help" url = QUrl.fromUserInput(url_str) if not QDesktopServices.openUrl(url): QMessageBox.warning( self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" ) def _open_bugs(self): url_str = "https://nr.mig5.net/forms/mig5/contact" url = QUrl.fromUserInput(url_str) if not QDesktopServices.openUrl(url): QMessageBox.warning( self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" ) # ----------------- Idle handlers ----------------- # def _apply_idle_minutes(self, minutes: int): minutes = max(0, int(minutes)) if not hasattr(self, "_idle_timer"): return if minutes == 0: self._idle_timer.stop() # If you’re currently locked, unlock when user disables the timer: if getattr(self, "_locked", False): try: self._locked = False if hasattr(self, "_lock_overlay"): self._lock_overlay.hide() except Exception: pass else: self._idle_timer.setInterval(minutes * 60 * 1000) if not getattr(self, "_locked", False): self._idle_timer.start() def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() return super().eventFilter(obj, event) def _enter_lock(self): if self._locked: return self._locked = True if self.menuBar(): self.menuBar().setEnabled(False) if self.statusBar(): self.statusBar().setEnabled(False) tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(False) self._lock_overlay.show() self._lock_overlay.raise_() @Slot() def _on_unlock_clicked(self): try: ok = self._prompt_for_key_until_valid(first_time=False) except Exception as e: QMessageBox.critical(self, "Unlock failed", str(e)) return if ok: self._locked = False self._lock_overlay.hide() if self.menuBar(): self.menuBar().setEnabled(True) if self.statusBar(): self.statusBar().setEnabled(True) tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(True) self._idle_timer.start() # ----------------- Close handlers ----------------- # def closeEvent(self, event): try: # Save window position self.settings.setValue("main/geometry", self.saveGeometry()) self.settings.setValue("main/windowState", self.saveState()) self.settings.setValue("main/maximized", self.isMaximized()) # Ensure we save any last pending edits to the db self._save_current() self.db.close() except Exception: pass super().closeEvent(event)