from __future__ import annotations import datetime import importlib.metadata import os import sys import re from pathlib import Path from PySide6.QtCore import ( QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent, QSignalBlocker, QDateTime, QTime, ) from PySide6.QtGui import ( QAction, QBrush, QColor, QCursor, QDesktopServices, QFont, QGuiApplication, QKeySequence, QTextCharFormat, QTextCursor, QTextListFormat, ) from PySide6.QtWidgets import ( QCalendarWidget, QDialog, QFileDialog, QMainWindow, QMenu, QMessageBox, QSizePolicy, QSplitter, QTableView, QTabWidget, QVBoxLayout, QWidget, QInputDialog, QLabel, QPushButton, QApplication, ) from .db import DBManager from .markdown_editor import MarkdownEditor from .find_bar import FindBar from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt from .lock_overlay import LockOverlay 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 .statistics_dialog import StatisticsDialog from .bug_report_dialog import BugReportDialog from . import strings from .tags_widget import PageTagsWidget from .toolbar import ToolBar from .theme import ThemeManager class MainWindow(QMainWindow): def __init__(self, themes: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(APP_NAME) self.setMinimumSize(1000, 650) self.themes = themes # Store the themes manager 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.themes.register_calendar(self.calendar) self.search = Search(self.db) self.search.openDateRequested.connect(self._load_selected_date) self.search.resultDatesChanged.connect(self._on_search_dates_changed) self.tags = PageTagsWidget(self.db) self.tags.tagActivated.connect(self._on_tag_activated) self.tags.tagAdded.connect(self._on_tag_added) # 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) left_layout.addWidget(self.search) left_layout.addWidget(self.tags) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # Create tab widget to hold multiple editors self.tab_widget = QTabWidget() self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self._close_tab) self.tab_widget.currentChanged.connect(self._on_tab_changed) self._prev_editor = None # Toolbar for controlling styling self.toolBar = ToolBar() self.addToolBar(self.toolBar) self._bind_toolbar() # Create the first editor tab self._create_new_tab() self._prev_editor = self.editor split = QSplitter() split.addWidget(left_panel) split.addWidget(self.tab_widget) split.setStretchFactor(1, 1) # Enable context menu on calendar for opening dates in new tabs self.calendar.setContextMenuPolicy(Qt.CustomContextMenu) self.calendar.customContextMenuRequested.connect( self._show_calendar_context_menu ) # Flag to prevent _on_date_changed when showing context menu self._showing_context_menu = False # Install event filter to catch right-clicks before selectionChanged fires self.calendar.installEventFilter(self) 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, themes=self.themes ) self.centralWidget().installEventFilter(self._lock_overlay) self._locked = False # reset idle timer on any key press anywhere in the app QApplication.instance().installEventFilter(self) # Focus on the editor self.setFocusPolicy(Qt.StrongFocus) self.editor.setFocusPolicy(Qt.StrongFocus) self.toolBar.setFocusPolicy(Qt.NoFocus) for w in self.toolBar.findChildren(QWidget): w.setFocusPolicy(Qt.NoFocus) QGuiApplication.instance().applicationStateChanged.connect( self._on_app_state_changed ) # Status bar for feedback self.statusBar().showMessage(strings._("main_window_ready"), 800) # Add findBar and add it to the statusBar # FindBar will get the current editor dynamically via a callable self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.statusBar().addPermanentWidget(self.findBar) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) # Menu bar (File) mb = self.menuBar() file_menu = mb.addMenu("&" + strings._("file")) act_save = QAction("&" + strings._("main_window_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("&" + strings._("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(strings._("main_window_settings_accessible_flag"), self) act_settings.setShortcut("Ctrl+G") act_settings.triggered.connect(self._open_settings) file_menu.addAction(act_settings) act_export = QAction(strings._("export_accessible_flag"), self) act_export.setShortcut("Ctrl+E") act_export.triggered.connect(self._export) file_menu.addAction(act_export) act_backup = QAction("&" + strings._("backup"), self) act_backup.setShortcut("Ctrl+Shift+B") act_backup.triggered.connect(self._backup) file_menu.addAction(act_backup) act_tags = QAction(strings._("main_window_manage_tags_accessible_flag"), self) act_tags.setShortcut("Ctrl+T") act_tags.triggered.connect(self.tags._open_manager) file_menu.addAction(act_tags) act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self) act_stats.setShortcut("Shift+Ctrl+S") act_stats.triggered.connect(self._open_statistics) file_menu.addAction(act_stats) file_menu.addSeparator() act_quit = QAction("&" + strings._("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("&" + strings._("navigate")) act_prev = QAction(strings._("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(strings._("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(strings._("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) act_find = QAction(strings._("find_on_page"), self) act_find.setShortcut(QKeySequence.Find) act_find.triggered.connect(self.findBar.show_bar) nav_menu.addAction(act_find) self.addAction(act_find) act_find_next = QAction(strings._("find_next"), self) act_find_next.setShortcut(QKeySequence.FindNext) act_find_next.triggered.connect(self.findBar.find_next) nav_menu.addAction(act_find_next) self.addAction(act_find_next) act_find_prev = QAction(strings._("find_previous"), self) act_find_prev.setShortcut(QKeySequence.FindPrevious) act_find_prev.triggered.connect(self.findBar.find_prev) nav_menu.addAction(act_find_prev) self.addAction(act_find_prev) # Help menu with drop-down help_menu = mb.addMenu("&" + strings._("help")) act_docs = QAction(strings._("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(strings._("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) act_version = QAction(strings._("version"), self) act_version.setShortcut("Ctrl+V") act_version.setShortcutContext(Qt.ApplicationShortcut) act_version.triggered.connect(self._open_version) help_menu.addAction(act_version) self.addAction(act_version) # Autosave self._dirty = False self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) self._save_timer.timeout.connect(self._save_current) # Reminders / alarms self._reminder_timers: list[QTimer] = [] # First load + mark dates in calendar with content if not self._load_yesterday_todos(): self._load_selected_date() self._refresh_calendar_marks() # Restore window position from settings self.settings = QSettings(APP_ORG, APP_NAME) self._restore_window_position() # re-apply all runtime color tweaks when theme changes self.themes.themeChanged.connect(lambda _t: self._retheme_overrides()) self._apply_calendar_text_colors() # apply once on startup so links / calendar colors are set immediately self._retheme_overrides() # Build any alarms for *today* from stored markdown self._rebuild_reminders_for_today() @property def editor(self) -> MarkdownEditor | None: """Get the currently active editor.""" return self.tab_widget.currentWidget() def _call_editor(self, method_name, *args): """ Call the relevant method of the MarkdownEditor class on bind """ getattr(self.editor, method_name)(*args) # ----------- Database connection/key management methods ------------ # def _try_connect(self) -> bool: """ Try to connect to the database. """ try: self.db = DBManager(self.cfg) ok = self.db.connect() return ok except Exception as e: if str(e) == "file is not a database": error = strings._("db_key_incorrect") else: error = str(e) QMessageBox.critical(self, strings._("db_database_error"), error) sys.exit(1) def _prompt_for_key_until_valid(self, first_time: bool) -> bool: """ Prompt for the SQLCipher key. """ if first_time: title = strings._("set_an_encryption_key") message = strings._("set_an_encryption_key_explanation") else: title = strings._("unlock_encrypted_notebook") message = strings._("unlock_encrypted_notebook_explanation") while True: dlg = KeyPrompt( self, title, message, initial_db_path=self.cfg.path, show_db_change=True ) if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() # Update DB path if the user changed it new_path = dlg.db_path() if new_path is not None and new_path != self.cfg.path: self.cfg.path = new_path # Persist immediately so next run is pre-filled with this file save_db_config(self.cfg) if self._try_connect(): return True # ----------------- Tab and date management ----------------- # def _current_date_iso(self) -> str: d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" def _date_key(self, qd: QDate) -> tuple[int, int, int]: return (qd.year(), qd.month(), qd.day()) def _index_for_date_insert(self, date: QDate) -> int: """Return the index where a tab for `date` should be inserted (ascending order).""" key = self._date_key(date) for i in range(self.tab_widget.count()): w = self.tab_widget.widget(i) d = getattr(w, "current_date", None) if isinstance(d, QDate) and d.isValid(): if self._date_key(d) > key: return i return self.tab_widget.count() def _reorder_tabs_by_date(self): """Reorder existing tabs by their date (ascending).""" bar = self.tab_widget.tabBar() dated, undated = [], [] for i in range(self.tab_widget.count()): w = self.tab_widget.widget(i) d = getattr(w, "current_date", None) if isinstance(d, QDate) and d.isValid(): dated.append((d, w)) else: undated.append(w) dated.sort(key=lambda t: self._date_key(t[0])) with QSignalBlocker(self.tab_widget): # Update labels to yyyy-MM-dd for d, w in dated: idx = self.tab_widget.indexOf(w) if idx != -1: self.tab_widget.setTabText(idx, d.toString("yyyy-MM-dd")) # Move dated tabs into target positions 0..len(dated)-1 for target_pos, (_, w) in enumerate(dated): cur = self.tab_widget.indexOf(w) if cur != -1 and cur != target_pos: bar.moveTab(cur, target_pos) # Keep any undated pages (if they ever exist) after the dated ones start = len(dated) for offset, w in enumerate(undated): cur = self.tab_widget.indexOf(w) target = start + offset if cur != -1 and cur != target: bar.moveTab(cur, target) def _tab_index_for_date(self, date: QDate) -> int: """Return the index of the tab showing `date`, or -1 if none.""" iso = date.toString("yyyy-MM-dd") for i in range(self.tab_widget.count()): w = self.tab_widget.widget(i) if ( hasattr(w, "current_date") and w.current_date.toString("yyyy-MM-dd") == iso ): return i return -1 def _open_date_in_tab(self, date: QDate): """Focus existing tab for `date`, or create it if needed. Returns the editor.""" idx = self._tab_index_for_date(date) if idx != -1: self.tab_widget.setCurrentIndex(idx) # keep calendar selection in sync (don’t trigger load) from PySide6.QtCore import QSignalBlocker with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(date) QTimer.singleShot(0, self._focus_editor_now) return self.tab_widget.widget(idx) # not open yet -> create return self._create_new_tab(date) def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor: """Create a new editor tab and return the editor instance.""" if date is None: date = self.calendar.selectedDate() # Deduplicate: if already open, just jump there existing = self._tab_index_for_date(date) if existing != -1: self.tab_widget.setCurrentIndex(existing) return self.tab_widget.widget(existing) editor = MarkdownEditor(self.themes) # Set up the editor's event connections editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) editor.cursorPositionChanged.connect(self._sync_toolbar) editor.textChanged.connect(self._on_text_changed) # Set tab title tab_title = date.toString("yyyy-MM-dd") # Add the tab index = self.tab_widget.addTab(editor, tab_title) self.tab_widget.setCurrentIndex(index) # Load the date's content self._load_date_into_editor(date) # Store the date with the editor so we can save it later editor.current_date = date # Insert at sorted position tab_title = date.toString("yyyy-MM-dd") pos = self._index_for_date_insert(date) index = self.tab_widget.insertTab(pos, editor, tab_title) self.tab_widget.setCurrentIndex(index) return editor def _close_tab(self, index: int): """Close a tab at the given index.""" if self.tab_widget.count() <= 1: # Don't close the last tab return editor = self.tab_widget.widget(index) if editor: # Save before closing self._save_editor_content(editor) self._dirty = False self.tab_widget.removeTab(index) def _on_tab_changed(self, index: int): """Handle tab change - reconnect toolbar and sync UI.""" if index < 0: return # If we had pending edits, flush them from the tab we're leaving. try: self._save_timer.stop() # avoid a pending autosave targeting the *new* tab except Exception: pass if getattr(self, "_prev_editor", None) is not None and self._dirty: self._save_editor_content(self._prev_editor) self._dirty = False # we just saved the edited tab # Update calendar selection to match the tab editor = self.tab_widget.widget(index) if editor and hasattr(editor, "current_date"): with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(editor.current_date) # update per-page tags for the active tab date_iso = editor.current_date.toString("yyyy-MM-dd") self._update_tag_views_for_date(date_iso) # Reconnect toolbar to new active editor self._sync_toolbar() # Focus the editor QTimer.singleShot(0, self._focus_editor_now) # Remember this as the "previous" editor for next switch self._prev_editor = editor def _date_from_calendar_pos(self, pos) -> QDate | None: """Translate a QCalendarWidget local pos to the QDate under the cursor.""" view: QTableView = self.calendar.findChild( QTableView, "qt_calendar_calendarview" ) if view is None: return None # Map calendar-local pos -> viewport pos vp_pos = view.viewport().mapFrom(self.calendar, pos) idx = view.indexAt(vp_pos) if not idx.isValid(): return None model = view.model() # Account for optional headers start_col = ( 0 if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader else 1 ) start_row = ( 0 if self.calendar.horizontalHeaderFormat() == QCalendarWidget.NoHorizontalHeader else 1 ) # Find index of day 1 (first cell belonging to current month) first_index = None for r in range(start_row, model.rowCount()): for c in range(start_col, model.columnCount()): if model.index(r, c).data() == 1: first_index = model.index(r, c) break if first_index: break if first_index is None: return None # Find index of the last day of the current month last_day = ( QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1) .addMonths(1) .addDays(-1) .day() ) last_index = None for r in range(model.rowCount() - 1, first_index.row() - 1, -1): for c in range(model.columnCount() - 1, start_col - 1, -1): if model.index(r, c).data() == last_day: last_index = model.index(r, c) break if last_index: break if last_index is None: return None # Determine if clicked cell belongs to prev/next month or current day = int(idx.data()) year = self.calendar.yearShown() month = self.calendar.monthShown() before_first = (idx.row() < first_index.row()) or ( idx.row() == first_index.row() and idx.column() < first_index.column() ) after_last = (idx.row() > last_index.row()) or ( idx.row() == last_index.row() and idx.column() > last_index.column() ) if before_first: if month == 1: month = 12 year -= 1 else: month -= 1 elif after_last: if month == 12: month = 1 year += 1 else: month += 1 qd = QDate(year, month, day) return qd if qd.isValid() else None def _show_calendar_context_menu(self, pos): self._showing_context_menu = True # so selectionChanged handler doesn't fire clicked_date = self._date_from_calendar_pos(pos) menu = QMenu(self) open_in_new_tab_action = menu.addAction(strings._("open_in_new_tab")) action = menu.exec_(self.calendar.mapToGlobal(pos)) self._showing_context_menu = False if action == open_in_new_tab_action and clicked_date and clicked_date.isValid(): self._open_date_in_tab(clicked_date) def _load_selected_date(self, date_iso=False, extra_data=False): """Load a date into the current editor""" if not date_iso: date_iso = self._current_date_iso() qd = QDate.fromString(date_iso, "yyyy-MM-dd") current_index = self.tab_widget.currentIndex() # Check if this date is already open in a *different* tab existing_idx = self._tab_index_for_date(qd) if existing_idx != -1 and existing_idx != current_index: # Date is already open in another tab - just switch to that tab self.tab_widget.setCurrentIndex(existing_idx) # Keep calendar in sync with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(qd) QTimer.singleShot(0, self._focus_editor_now) return # Date not open in any other tab - load it into current tab # Keep calendar in sync with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(qd) self._load_date_into_editor(qd, extra_data) self.editor.current_date = qd # Update tab title if current_index >= 0: self.tab_widget.setTabText(current_index, date_iso) # Keep tabs sorted by date self._reorder_tabs_by_date() # sync tags self._update_tag_views_for_date(date_iso) def _load_date_into_editor(self, date: QDate, extra_data=False): """Load a specific date's content into a given editor.""" date_iso = date.toString("yyyy-MM-dd") text = self.db.get_entry(date_iso) if extra_data: # Append extra data as markdown if text and not text.endswith("\n"): text += "\n" text += extra_data # Force a save now so we don't lose it. self._set_editor_markdown_preserve_view(text) self._dirty = True self._save_date(date_iso, True) self._set_editor_markdown_preserve_view(text) self._dirty = False def _set_editor_markdown_preserve_view(self, markdown: str): # Save caret/selection and scroll cur = self.editor.textCursor() old_pos, old_anchor = cur.position(), cur.anchor() v = self.editor.verticalScrollBar().value() h = self.editor.horizontalScrollBar().value() # Only touch the doc if it actually changed self.editor.blockSignals(True) if self.editor.to_markdown() != markdown: self.editor.from_markdown(markdown) self.editor.blockSignals(False) # Restore scroll first self.editor.verticalScrollBar().setValue(v) self.editor.horizontalScrollBar().setValue(h) # Restore caret/selection (bounded to new doc length) doc_length = self.editor.document().characterCount() - 1 old_pos = min(old_pos, doc_length) old_anchor = min(old_anchor, doc_length) cur = self.editor.textCursor() cur.setPosition(old_anchor) mode = ( QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor ) cur.setPosition(old_pos, mode) self.editor.setTextCursor(cur) # Refresh highlights if the theme changed if hasattr(self, "findBar"): self.findBar.refresh() def _save_editor_content(self, editor: MarkdownEditor): """Save a specific editor's content to its associated date.""" # Skip if DB is missing or not connected somehow. if not getattr(self, "db", None) or getattr(self.db, "conn", None) is None: return if not hasattr(editor, "current_date"): return date_iso = editor.current_date.toString("yyyy-MM-dd") md = editor.to_markdown() self.db.save_new_version(date_iso, md, note=strings._("autosave")) def _on_text_changed(self): self._dirty = True self._save_timer.start(5000) # 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._create_new_tab(today) def _load_yesterday_todos(self): if not self.cfg.move_todos: return yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") text = self.db.get_entry(yesterday_str) unchecked_items = [] # Split into lines and find unchecked checkbox items lines = text.split("\n") remaining_lines = [] for line in lines: # Check for unchecked markdown checkboxes: - [ ] or - [☐] if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( r"^\s*-\s*\[☐\]\s+", line ): # Extract the text after the checkbox item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) unchecked_items.append(f"- [ ] {item_text}") else: # Keep all other lines remaining_lines.append(line) # Save modified content back if we moved items if unchecked_items: modified_text = "\n".join(remaining_lines) self.db.save_new_version( yesterday_str, modified_text, strings._("unchecked_checkbox_items_moved_to_next_day"), ) # Join unchecked items into markdown format unchecked_str = "\n".join(unchecked_items) + "\n" # Load the unchecked items into the current editor self._load_selected_date(False, unchecked_str) else: return False 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 into current tab. """ # Skip if we're showing a context menu (right-click shouldn't load dates) if getattr(self, "_showing_context_menu", False): return # Stop pending autosave and persist current buffer if needed try: self._save_timer.stop() except Exception: pass # Save the current editor's content if dirty if hasattr(self.editor, "current_date") and self._dirty: prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd") self._save_date(prev_date_iso, explicit=False) # Now load the newly selected date new_date = self.calendar.selectedDate() current_index = self.tab_widget.currentIndex() # Check if this date is already open in a *different* tab existing_idx = self._tab_index_for_date(new_date) if existing_idx != -1 and existing_idx != current_index: # Date is already open in another tab - just switch to that tab self.tab_widget.setCurrentIndex(existing_idx) QTimer.singleShot(0, self._focus_editor_now) return # Date not open in any other tab - load it into current tab self._load_date_into_editor(new_date) self.editor.current_date = new_date # Update tab title if current_index >= 0: self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd")) # Update tags for the newly loaded page date_iso = new_date.toString("yyyy-MM-dd") self._update_tag_views_for_date(date_iso) # Keep tabs sorted by date self._reorder_tabs_by_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. """ # Bail out if there is no DB connection (can happen during construction/teardown) if not getattr(self.db, "conn", None): return if not self._dirty and not explicit: return text = self.editor.to_markdown() if hasattr(self, "editor") else "" self.db.save_new_version(date_iso, text, note) self._dirty = False self._refresh_calendar_marks() # Feedback in the status bar from datetime import datetime as _dt self.statusBar().showMessage( strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", 2000 ) def _save_current(self, explicit: bool = False): """Save the current editor's content.""" 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 = strings._("autosave") # Save the current editor's date date_iso = self.editor.current_date.toString("yyyy-MM-dd") self._save_date(date_iso, explicit, note) try: self._save_timer.start() except Exception: pass # ----------------- Some theme helpers -------------------# def _retheme_overrides(self): self._apply_calendar_text_colors() self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self.calendar.update() self.editor.viewport().update() def _apply_calendar_text_colors(self): pal = self.palette() txt = pal.windowText().color() fmt = QTextCharFormat() fmt.setForeground(txt) # Use normal text color for weekends self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) # --------------- Search sidebar/results helpers ---------------- # def _on_search_dates_changed(self, date_strs: list[str]): dates = set() for ds in date_strs or []: qd = QDate.fromString(ds, "yyyy-MM-dd") if qd.isValid(): dates.add(qd) self._apply_search_highlights(dates) def _apply_search_highlights(self, dates: set): pal = self.palette() base = pal.base().color() hi = pal.highlight().color() # Blend highlight with base so it looks soft in both modes blend = QColor( (2 * hi.red() + base.red()) // 3, (2 * hi.green() + base.green()) // 3, (2 * hi.blue() + base.blue()) // 3, ) yellow = QBrush(blend) old = getattr(self, "_search_highlighted_dates", set()) for d in old - dates: # clear removed fmt = self.calendar.dateTextFormat(d) fmt.setBackground(Qt.transparent) self.calendar.setDateTextFormat(d, fmt) for d in dates: # apply new/current fmt = self.calendar.dateTextFormat(d) fmt.setBackground(yellow) self.calendar.setDateTextFormat(d, fmt) self._search_highlighted_dates = dates def _refresh_calendar_marks(self): """Make days with entries bold, but keep any search highlight backgrounds.""" for d in getattr(self, "_marked_dates", set()): fmt = self.calendar.dateTextFormat(d) fmt.setFontWeight(QFont.Weight.Normal) # remove bold only self.calendar.setDateTextFormat(d, fmt) self._marked_dates = set() if self.db.conn is not None: for date_iso in self.db.dates_with_content(): qd = QDate.fromString(date_iso, "yyyy-MM-dd") if qd.isValid(): fmt = self.calendar.dateTextFormat(qd) fmt.setFontWeight(QFont.Weight.Bold) # add bold only self.calendar.setDateTextFormat(qd, fmt) self._marked_dates.add(qd) # -------------------- UI handlers ------------------- # def _bind_toolbar(self): if getattr(self, "_toolbar_bound", False): return tb = self.toolBar # keep refs so we never create new lambdas (prevents accidental dupes) self._tb_bold = lambda: self._call_editor("apply_weight") self._tb_italic = lambda: self._call_editor("apply_italic") self._tb_strike = lambda: self._call_editor("apply_strikethrough") self._tb_code = lambda: self._call_editor("apply_code") self._tb_heading = lambda level: self._call_editor("apply_heading", level) self._tb_bullets = lambda: self._call_editor("toggle_bullets") self._tb_numbers = lambda: self._call_editor("toggle_numbers") self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes") self._tb_alarm = self._on_alarm_requested tb.boldRequested.connect(self._tb_bold) tb.italicRequested.connect(self._tb_italic) tb.strikeRequested.connect(self._tb_strike) tb.codeRequested.connect(self._tb_code) tb.headingRequested.connect(self._tb_heading) tb.bulletsRequested.connect(self._tb_bullets) tb.numbersRequested.connect(self._tb_numbers) tb.checkboxesRequested.connect(self._tb_checkboxes) tb.alarmRequested.connect(self._tb_alarm) tb.insertImageRequested.connect(self._on_insert_image) tb.historyRequested.connect(self._open_history) self._toolbar_bound = True def _sync_toolbar(self): fmt = self.editor.currentCharFormat() c = self.editor.textCursor() # Block signals so setChecked() doesn't re-trigger actions QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actStrike) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actItalic.setChecked(fmt.fontItalic()) 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) QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH3) 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)) # ----------- Alarms handler ------------# def _on_alarm_requested(self): """Create a one-shot reminder based on the current line in the editor.""" editor = getattr(self, "editor", None) if editor is None: return # Use the current line in the markdown editor as the reminder text try: line_text = editor.get_current_line_text().strip() except AttributeError: c = editor.textCursor() line_text = c.block().text().strip() # Ask user for a time today in HH:MM format time_str, ok = QInputDialog.getText( self, strings._("set_reminder"), strings._("set_reminder_prompt") + " (HH:MM)", ) if not ok or not time_str.strip(): return try: hour, minute = map(int, time_str.strip().split(":", 1)) except ValueError: QMessageBox.warning( self, strings._("invalid_time_title"), strings._("invalid_time_message"), ) return now = QDateTime.currentDateTime() target = QDateTime(now.date(), QTime(hour, minute)) t = QTime(hour, minute) if not t.isValid(): QMessageBox.warning( self, strings._("invalid_time_title"), strings._("invalid_time_message"), ) return # Normalise to HH:MM time_str = f"{t.hour():02d}:{t.minute():02d}" # Insert / update ⏰ in the editor text if hasattr(editor, "insert_alarm_marker"): editor.insert_alarm_marker(time_str) # Rebuild timers, but only if this page is for "today" self._rebuild_reminders_for_today() def _show_flashing_reminder(self, text: str): """ Show a small flashing dialog and request attention from the OS. Called by reminder timers. """ # Ask OS to flash / bounce our app in the dock/taskbar QApplication.alert(self, 0) # Try to bring the window to the front self.showNormal() self.raise_() self.activateWindow() # Simple dialog with a flashing background to reinforce the alert dlg = QDialog(self) dlg.setWindowTitle(strings._("reminder")) dlg.setModal(True) layout = QVBoxLayout(dlg) label = QLabel(text) label.setWordWrap(True) layout.addWidget(label) btn = QPushButton(strings._("dismiss")) btn.clicked.connect(dlg.accept) layout.addWidget(btn) flash_timer = QTimer(dlg) flash_state = {"on": False} def toggle(): flash_state["on"] = not flash_state["on"] if flash_state["on"]: dlg.setStyleSheet("background-color: #3B3B3B;") else: dlg.setStyleSheet("") flash_timer.timeout.connect(toggle) flash_timer.start(500) # ms dlg.exec() flash_timer.stop() def _clear_reminder_timers(self): """Stop and delete any existing reminder timers.""" for t in self._reminder_timers: try: t.stop() t.deleteLater() except Exception: pass self._reminder_timers = [] def _rebuild_reminders_for_today(self): """ Scan the markdown for today's date and create QTimers only for alarms on the *current day* (system date). """ # We only ever set timers for the real current date today = QDate.currentDate() today_iso = today.toString("yyyy-MM-dd") # Clear any previously scheduled "today" reminders self._clear_reminder_timers() # Prefer live editor content if it is showing today's page text = "" if ( hasattr(self, "editor") and hasattr(self.editor, "current_date") and self.editor.current_date == today ): text = self.editor.to_markdown() else: # Fallback to DB: still only today's date text = self.db.get_entry(today_iso) if hasattr(self, "db") else "" if not text: return now = QDateTime.currentDateTime() for line in text.splitlines(): # Look for "⏰ HH:MM" anywhere in the line m = re.search(r"⏰\s*(\d{1,2}):(\d{2})", line) if not m: continue hour = int(m.group(1)) minute = int(m.group(2)) t = QTime(hour, minute) if not t.isValid(): continue target = QDateTime(today, t) # Skip alarms that are already in the past if target <= now: continue # The reminder text is the part before the symbol reminder_text = line.split("⏰", 1)[0].strip() if not reminder_text: reminder_text = strings._("reminder_no_text_fallback") msecs = now.msecsTo(target) timer = QTimer(self) timer.setSingleShot(True) timer.timeout.connect( lambda txt=reminder_text: self._show_flashing_reminder(txt) ) timer.start(msecs) self._reminder_timers.append(timer) # ----------- History handler ------------# def _open_history(self): if hasattr(self.editor, "current_date"): date_iso = self.editor.current_date.toString("yyyy-MM-dd") else: 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() # ----------- Image insert handler ------------# def _on_insert_image(self): # Let the user pick one or many images paths, _ = QFileDialog.getOpenFileNames( self, strings._("insert_images"), "", strings._("images") + "(*.png *.jpg *.jpeg *.bmp *.gif *.webp)", ) if not paths: return # Insert each image for path_str in paths: self.editor.insert_image_from_path(Path(path_str)) # ----------- Tags handler ----------------# def _update_tag_views_for_date(self, date_iso: str): if hasattr(self, "tags"): self.tags.set_current_date(date_iso) def _on_tag_added(self): """Called when a tag is added - trigger autosave for current page""" # Use QTimer to defer the save slightly, avoiding re-entrancy issues from PySide6.QtCore import QTimer QTimer.singleShot(0, self._do_tag_save) def _do_tag_save(self): """Actually perform the save after tag is added""" if hasattr(self, "editor") and hasattr(self.editor, "current_date"): date_iso = self.editor.current_date.toString("yyyy-MM-dd") # Get current editor content text = self.editor.to_markdown() # Save the content (or blank if page is empty) # This ensures the page shows up in tag browser self.db.save_new_version(date_iso, text, note="Tag added") self._dirty = False self._refresh_calendar_marks() from datetime import datetime as _dt self.statusBar().showMessage( strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", 2000, ) def _on_tag_activated(self, tag_name_or_date: str): # If it's a date (YYYY-MM-DD format), load it if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2: self._load_selected_date(tag_name_or_date) else: # It's a tag name, open the tag browser from .tag_browser import TagBrowserDialog dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date) dlg.openDateRequested.connect(self._load_selected_date) dlg.tagsModified.connect(self._refresh_current_page_tags) dlg.exec() def _refresh_current_page_tags(self): """Refresh the tag chips for the current page (after tag browser changes)""" if hasattr(self, "tags") and hasattr(self.editor, "current_date"): date_iso = self.editor.current_date.toString("yyyy-MM-dd") self.tags.set_current_date(date_iso) if self.tags.toggle_btn.isChecked(): self.tags._reload_tags() # ----------- 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) self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) # 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, strings._("reopen_failed"), strings._("could_not_unlock_database_at_new_path"), ) return self._load_selected_date() self._refresh_calendar_marks() # ------------ Statistics handler --------------- # def _open_statistics(self): if not getattr(self, "db", None) or self.db.conn is None: return dlg = StatisticsDialog(self.db, self) if hasattr(dlg, "_heatmap"): def on_date_clicked(d: datetime.date): qd = QDate(d.year, d.month, d.day) self._open_date_in_tab(qd) dlg._heatmap.date_clicked.connect(on_date_clicked) dlg.exec() # ------------ 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 = strings._("unencrypted_export") warning_message = strings._("unencrypted_export_warning") 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 = ( "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;" "Markdown (*.md);;" "SQL (*.sql);;" ) start_dir = os.path.join(os.path.expanduser("~"), "Documents") filename, selected_filter = QFileDialog.getSaveFileName( self, strings._("export_entries"), start_dir, filters ) if not filename: return # user cancelled default_ext = { "JSON (*.json)": ".json", "CSV (*.csv)": ".csv", "HTML (*.html)": ".html", "Markdown (*.md)": ".md", "SQL (*.sql)": ".sql", }.get(selected_filter, ".md") if not Path(filename).suffix: filename += default_ext try: entries = self.db.get_all_entries() if 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("Markdown"): self.db.export_markdown(entries, filename) elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: raise ValueError(strings._("unrecognised_extension")) QMessageBox.information( self, strings._("export_complete"), strings._("saved_to") + f" {filename}", ) except Exception as e: QMessageBox.critical(self, strings._("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, strings._("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, strings._("backup_complete"), strings._("saved_to") + f" {filename}", ) except Exception as e: QMessageBox.critical(self, strings._("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, strings._("documentation"), strings._("couldnt_open") + url.toDisplayString(), ) def _open_bugs(self): dlg = BugReportDialog(self) dlg.exec() def _open_version(self): version = importlib.metadata.version("bouquin") version_formatted = f"{APP_NAME} {version}" QMessageBox.information(self, strings._("version"), version_formatted) # ----------------- 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 currently locked, unlock when user disables the timer: if getattr(self, "_locked", False): self._locked = False if hasattr(self, "_lock_overlay"): self._lock_overlay.hide() else: self._idle_timer.setInterval(minutes * 60 * 1000) if not getattr(self, "_locked", False): self._idle_timer.start() def eventFilter(self, obj, event): # Catch right-clicks on calendar BEFORE selectionChanged can fire if obj == self.calendar and event.type() == QEvent.MouseButtonPress: # QMouseEvent in PySide6 if event.button() == Qt.RightButton: self._showing_context_menu = True if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): QTimer.singleShot(0, self._focus_editor_now) return super().eventFilter(obj, event) def _enter_lock(self): """ Trigger the lock overlay and disable widgets """ if self._locked: return self._locked = True if self.menuBar(): self.menuBar().setEnabled(False) if self.statusBar(): self.statusBar().setEnabled(False) self.statusBar().hide() tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(False) tb.hide() self._lock_overlay.show() self._lock_overlay.raise_() @Slot() def _on_unlock_clicked(self): """ Prompt for key to unlock screen If successful, re-enable widgets """ try: ok = self._prompt_for_key_until_valid(first_time=False) except Exception as e: QMessageBox.critical(self, strings._("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) self.statusBar().show() tb = getattr(self, "toolBar", None) if tb: tb.setEnabled(True) tb.show() self._idle_timer.start() QTimer.singleShot(0, self._focus_editor_now) # ----------------- Close handlers ----------------- # def closeEvent(self, event): # Persist geometry if settings exist (window might be half-initialized). if getattr(self, "settings", None) is not None: try: self.settings.setValue("main/geometry", self.saveGeometry()) self.settings.setValue("main/windowState", self.saveState()) self.settings.setValue("main/maximized", self.isMaximized()) except Exception: pass # Stop timers if present to avoid late autosaves firing during teardown. for _t in ("_autosave_timer", "_idle_timer"): t = getattr(self, _t, None) if t: t.stop() # Save content from tabs if the database is still connected db = getattr(self, "db", None) conn = getattr(db, "conn", None) tw = getattr(self, "tab_widget", None) if db is not None and conn is not None and tw is not None: try: for i in range(tw.count()): editor = tw.widget(i) if editor is not None: self._save_editor_content(editor) except Exception: # Don't let teardown crash if one tab fails to save. pass try: db.close() except Exception: pass super().closeEvent(event) # ----------------- Below logic helps focus the editor ----------------- # def _focus_editor_now(self): """Give focus to the editor and ensure the caret is visible.""" if getattr(self, "_locked", False): return if not self.isActiveWindow(): return # Belt-and-suspenders: do it now and once more on the next tick self.editor.setFocus(Qt.ActiveWindowFocusReason) self.editor.ensureCursorVisible() QTimer.singleShot( 0, lambda: ( ( self.editor.setFocus(Qt.ActiveWindowFocusReason) if self.editor else None ), self.editor.ensureCursorVisible() if self.editor else None, ), ) def _on_app_state_changed(self, state): # Called on macOS/Wayland/Windows when the whole app re-activates if state == Qt.ApplicationActive and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now) def changeEvent(self, ev): # Called on some platforms when the window's activation state flips super().changeEvent(ev) if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now)