Reminders improvements
* 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
This commit is contained in:
parent
d809244cf8
commit
3106d408ab
6 changed files with 200 additions and 54 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
if now is None:
|
||||
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)
|
||||
)
|
||||
|
||||
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,22 +517,29 @@ 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
|
||||
if reminder_key in self._fired_reminders:
|
||||
continue
|
||||
|
||||
# Mark as fired and emit
|
||||
self._fired_reminders[reminder_key] = now
|
||||
self.reminderTriggered.emit(reminder.text)
|
||||
|
||||
# For ONCE reminders, deactivate after firing
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue