diff --git a/CHANGELOG.md b/CHANGELOG.md index a27c654..1c136db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter) * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present * Invoicing should not be enabled by default + * Fix Reminders to fire right on the minute after adding them during runtime + * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings. # 0.7.0 diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 332f13d..519a891 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -277,6 +277,9 @@ "enable_tags_feature": "Enable Tags", "enable_time_log_feature": "Enable Time Logging", "enable_reminders_feature": "Enable Reminders", + "reminders_webhook_section_title": "Send Reminders to a webhook", + "reminders_webhook_url_label":"Webhook URL", + "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)", "enable_documents_feature": "Enable storing of documents", "pomodoro_time_log_default_text": "Focus session", "toolbar_pomodoro_timer": "Time-logging timer", diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 617a98a..89bc9a9 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -58,7 +58,7 @@ from .key_prompt import KeyPrompt from .lock_overlay import LockOverlay from .markdown_editor import MarkdownEditor from .pomodoro_timer import PomodoroManager -from .reminders import UpcomingRemindersWidget +from .reminders import UpcomingRemindersWidget, ReminderWebHook from .save_dialog import SaveDialog from .search import Search from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config @@ -115,6 +115,7 @@ class MainWindow(QMainWindow): self.tags.tagAdded.connect(self._on_tag_added) self.upcoming_reminders = UpcomingRemindersWidget(self.db) + self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget @@ -1335,6 +1336,11 @@ class MainWindow(QMainWindow): # Turned off -> cancel any running timer and remove the widget self.pomodoro_manager.cancel_timer() + def _send_reminder_webhook(self, text: str): + if self.cfg.reminders and self.cfg.reminders_webhook_url: + reminder_webhook = ReminderWebHook(text) + reminder_webhook._send() + def _show_flashing_reminder(self, text: str): """ Show a small flashing dialog and request attention from the OS. @@ -1563,6 +1569,12 @@ class MainWindow(QMainWindow): self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) + self.cfg.reminders_webhook_url = getattr( + new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url + ) + self.cfg.reminders_webhook_secret = getattr( + new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret + ) self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) diff --git a/bouquin/reminders.py b/bouquin/reminders.py index 9fc096a..50929c5 100644 --- a/bouquin/reminders.py +++ b/bouquin/reminders.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import Enum from typing import Optional -from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot +from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot, QObject from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, @@ -32,6 +32,9 @@ from PySide6.QtWidgets import ( from . import strings from .db import DBManager +from .settings import load_db_config + +import requests class ReminderType(Enum): @@ -332,43 +335,36 @@ class UpcomingRemindersWidget(QFrame): main.addWidget(self.body) # Timer to check and fire reminders - # Start by syncing to the next minute boundary - self._check_timer = QTimer(self) - self._check_timer.timeout.connect(self._check_reminders) + # + # We tick once per second, but only hit the DB when the clock is + # exactly on a :00 second. That way a reminder for HH:MM fires at + # HH:MM:00, independent of when it was created. + self._tick_timer = QTimer(self) + self._tick_timer.setInterval(1000) # 1 second + self._tick_timer.timeout.connect(self._on_tick) + self._tick_timer.start() - # Calculate milliseconds until next minute (HH:MM:00) + # Also check once on startup so we don't miss reminders that + # should have fired a moment ago when the app wasn't running. + QTimer.singleShot(0, self._check_reminders) + + def _on_tick(self) -> None: + """Called every second; run reminder check only on exact minute boundaries.""" now = QDateTime.currentDateTime() - current_second = now.time().second() - current_msec = now.time().msec() - - # Milliseconds until next minute - ms_until_next_minute = (60 - current_second) * 1000 - current_msec - - # Start with a single-shot to sync to the minute - self._sync_timer = QTimer(self) - self._sync_timer.setSingleShot(True) - self._sync_timer.timeout.connect(self._start_regular_timer) - self._sync_timer.start(ms_until_next_minute) - - # Also check immediately in case there are pending reminders - QTimer.singleShot(1000, self._check_reminders) + if now.time().second() == 0: + # Only do the heavier DB work once per minute, at HH:MM:00, + # so reminders are aligned to the clock and not to when they + # were created. + self._check_reminders(now) def __del__(self): """Cleanup timers when widget is destroyed.""" try: - if hasattr(self, "_check_timer") and self._check_timer: - self._check_timer.stop() - if hasattr(self, "_sync_timer") and self._sync_timer: - self._sync_timer.stop() - except: + if hasattr(self, "_tick_timer") and self._tick_timer: + self._tick_timer.stop() + except Exception: pass # Ignore any cleanup errors - def _start_regular_timer(self): - """Start the regular check timer after initial sync.""" - # Now we're at a minute boundary, check and start regular timer - self._check_reminders() - self._check_timer.start(60000) # Check every minute - def _on_toggle(self, checked: bool): """Toggle visibility of reminder list.""" self.body.setVisible(checked) @@ -492,21 +488,28 @@ class UpcomingRemindersWidget(QFrame): return False - def _check_reminders(self): - """Check if any reminders should fire now.""" + def _check_reminders(self, now: QDateTime | None = None): + """ + Check and trigger due reminders. + + This uses absolute clock time, so a reminder for HH:MM will fire + when the system clock reaches HH:MM:00, independent of when the + reminder was created. + """ # Guard: Check if database connection is valid if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: return - now = QDateTime.currentDateTime() - today = QDate.currentDate() - - # Round current time to the minute (set seconds to 0) - current_minute = QDateTime( - today, QTime(now.time().hour(), now.time().minute(), 0) - ) + if now is None: + now = QDateTime.currentDateTime() + today = now.date() reminders = self._db.get_all_reminders() + + # Small grace window (in seconds) so we still fire reminders if + # the app was just opened or the event loop was briefly busy. + GRACE_WINDOW_SECS = 120 # 2 minutes + for reminder in reminders: if not reminder.active: continue @@ -514,28 +517,35 @@ class UpcomingRemindersWidget(QFrame): if not self._should_fire_on_date(reminder, today): continue - # Parse time + # Parse time: stored as "HH:MM", we treat that as HH:MM:00 hour, minute = map(int, reminder.time_str.split(":")) target = QDateTime(today, QTime(hour, minute, 0)) - # Fire if we've passed the target minute (within last 2 minutes to catch missed ones) - seconds_diff = current_minute.secsTo(target) - if -120 <= seconds_diff <= 0: - # Check if we haven't already fired this one + # Skip if this reminder is still in the future + if now < target: + continue + + # How long ago should this reminder have fired? + seconds_late = target.secsTo(now) # target -> now + + if 0 <= seconds_late <= GRACE_WINDOW_SECS: + # Check if we haven't already fired this occurrence if not hasattr(self, "_fired_reminders"): self._fired_reminders = {} reminder_key = (reminder.id, target.toString()) - # Only fire once per reminder per target time - if reminder_key not in self._fired_reminders: - self._fired_reminders[reminder_key] = current_minute - self.reminderTriggered.emit(reminder.text) + if reminder_key in self._fired_reminders: + continue - # For ONCE reminders, deactivate after firing - if reminder.reminder_type == ReminderType.ONCE: - self._db.update_reminder_active(reminder.id, False) - self.refresh() # Refresh the list to show deactivated reminder + # Mark as fired and emit + self._fired_reminders[reminder_key] = now + self.reminderTriggered.emit(reminder.text) + + # For ONCE reminders, deactivate after firing + if reminder.reminder_type == ReminderType.ONCE: + self._db.update_reminder_active(reminder.id, False) + self.refresh() # Refresh the list to show deactivated reminder @Slot() def _add_reminder(self): @@ -834,3 +844,33 @@ class ManageRemindersDialog(QDialog): if reply == QMessageBox.Yes: self._db.delete_reminder(reminder.id) self._load_reminders() + + +class ReminderWebHook: + def __init__(self, text): + self.text = text + self.cfg = load_db_config() + + def _send(self): + payload: dict[str, str] = { + "reminder": self.text, + } + + url = self.cfg.reminders_webhook_url + secret = self.cfg.reminders_webhook_secret + + _headers = {} + if secret: + _headers["X-Bouquin-Secret"] = secret + + if url: + try: + resp = requests.post( + url, + json=payload, + timeout=10, + headers=_headers, + ) + except Exception: + # We did our best + pass diff --git a/bouquin/settings.py b/bouquin/settings.py index 2aaf5de..0c5b614 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -45,6 +45,8 @@ def load_db_config() -> DBConfig: tags = s.value("ui/tags", True, type=bool) time_log = s.value("ui/time_log", True, type=bool) reminders = s.value("ui/reminders", True, type=bool) + reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str) + reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str) documents = s.value("ui/documents", True, type=bool) invoicing = s.value("ui/invoicing", False, type=bool) locale = s.value("ui/locale", "en", type=str) @@ -58,6 +60,8 @@ def load_db_config() -> DBConfig: tags=tags, time_log=time_log, reminders=reminders, + reminders_webhook_url=reminders_webhook_url, + reminders_webhook_secret=reminders_webhook_secret, documents=documents, invoicing=invoicing, locale=locale, @@ -75,6 +79,8 @@ def save_db_config(cfg: DBConfig) -> None: s.setValue("ui/tags", str(cfg.tags)) s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/reminders", str(cfg.reminders)) + s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url)) + s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret)) s.setValue("ui/documents", str(cfg.documents)) s.setValue("ui/invoicing", str(cfg.invoicing)) s.setValue("ui/locale", str(cfg.locale)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 6ce6255..8835493 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -23,6 +23,7 @@ from PySide6.QtWidgets import ( QSpinBox, QTabWidget, QTextEdit, + QToolButton, QVBoxLayout, QWidget, ) @@ -44,7 +45,7 @@ class SettingsDialog(QDialog): self.current_settings = load_db_config() - self.setMinimumWidth(480) + self.setMinimumWidth(600) self.setSizeGripEnabled(True) # --- Tabs ---------------------------------------------------------- @@ -189,11 +190,66 @@ class SettingsDialog(QDialog): self.invoicing.setEnabled(False) self.time_log.toggled.connect(self._on_time_log_toggled) + # --- Reminders feature + webhook options ------------------------- self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders.setChecked(self.current_settings.reminders) + self.reminders.toggled.connect(self._on_reminders_toggled) self.reminders.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.reminders) + # Container for reminder-specific options, indented under the checkbox + self.reminders_options_container = QWidget() + reminders_options_layout = QVBoxLayout(self.reminders_options_container) + reminders_options_layout.setContentsMargins(24, 0, 0, 0) + reminders_options_layout.setSpacing(4) + + self.reminders_options_toggle = QToolButton() + self.reminders_options_toggle.setText( + strings._("reminders_webhook_section_title") + ) + self.reminders_options_toggle.setCheckable(True) + self.reminders_options_toggle.setChecked(False) + self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.reminders_options_toggle.setArrowType(Qt.RightArrow) + self.reminders_options_toggle.clicked.connect( + self._on_reminders_options_toggled + ) + + toggle_row = QHBoxLayout() + toggle_row.addWidget(self.reminders_options_toggle) + toggle_row.addStretch() + reminders_options_layout.addLayout(toggle_row) + + # Actual options (labels + QLineEdits) + self.reminders_options_widget = QWidget() + options_form = QFormLayout(self.reminders_options_widget) + options_form.setContentsMargins(0, 0, 0, 0) + options_form.setSpacing(4) + + self.reminders_webhook_url = QLineEdit( + self.current_settings.reminders_webhook_url or "" + ) + self.reminders_webhook_secret = QLineEdit( + self.current_settings.reminders_webhook_secret or "" + ) + self.reminders_webhook_secret.setEchoMode(QLineEdit.Password) + + options_form.addRow( + strings._("reminders_webhook_url_label") + ":", + self.reminders_webhook_url, + ) + options_form.addRow( + strings._("reminders_webhook_secret_label") + ":", + self.reminders_webhook_secret, + ) + + reminders_options_layout.addWidget(self.reminders_options_widget) + + features_layout.addWidget(self.reminders_options_container) + + self.reminders_options_container.setVisible(self.reminders.isChecked()) + self.reminders_options_widget.setVisible(False) + self.documents = QCheckBox(strings._("enable_documents_feature")) self.documents.setChecked(self.current_settings.documents) self.documents.setCursor(Qt.PointingHandCursor) @@ -388,6 +444,9 @@ class SettingsDialog(QDialog): tags=self.tags.isChecked(), time_log=self.time_log.isChecked(), reminders=self.reminders.isChecked(), + reminders_webhook_url=self.reminders_webhook_url.text().strip() or None, + reminders_webhook_secret=self.reminders_webhook_secret.text().strip() + or None, documents=self.documents.isChecked(), invoicing=( self.invoicing.isChecked() if self.time_log.isChecked() else False @@ -414,6 +473,30 @@ class SettingsDialog(QDialog): self.parent().themes.set(selected_theme) self.accept() + def _on_reminders_options_toggled(self, checked: bool) -> None: + """ + Expand/collapse the advanced reminders options (webhook URL/secret). + """ + if checked: + self.reminders_options_toggle.setArrowType(Qt.DownArrow) + self.reminders_options_widget.show() + else: + self.reminders_options_toggle.setArrowType(Qt.RightArrow) + self.reminders_options_widget.hide() + + def _on_reminders_toggled(self, checked: bool) -> None: + """ + Conditionally show reminder webhook options depending + on if the reminders feature is toggled on or off. + """ + if hasattr(self, "reminders_options_container"): + self.reminders_options_container.setVisible(checked) + + # When turning reminders off, also collapse the section + if not checked and hasattr(self, "reminders_options_toggle"): + self.reminders_options_toggle.setChecked(False) + self._on_reminders_options_toggled(False) + def _on_time_log_toggled(self, checked: bool) -> None: """ Enforce 'invoicing depends on time logging'.