✨ feat(launcher, plugins): add reminders plugin with time-based notifications
This commit is contained in:
3 files changed
+442
No files matched your search
@@ -98,6 +98,19 @@
|
||||
"kanban": {
|
||||
"icon": "kanban",
|
||||
"description": "Kanban - Task management with kanban-style columns"
|
||||
},
|
||||
"cal": {
|
||||
"examples": [
|
||||
"cal today",
|
||||
"cal next (press Enter to continue)",
|
||||
"cal previous (press Enter to continue)"
|
||||
],
|
||||
"icon": "calendar",
|
||||
"description": "Calendar - View calendar, navigate dates, and get date/time info"
|
||||
},
|
||||
"remind": {
|
||||
"icon": "timer_on",
|
||||
"description": "Reminders - Set time-based reminders with notifications"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -94,6 +94,8 @@ class PluginManager:
|
||||
"pomodoro",
|
||||
"websearch",
|
||||
"kanban",
|
||||
"calendar",
|
||||
"reminders",
|
||||
]
|
||||
|
||||
for plugin_name in default_plugins:
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Reminders plugin for the launcher.
|
||||
Set time-based reminders with notify-send notifications.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from modules.launcher.plugin_base import PluginBase
|
||||
from modules.launcher.result import Result
|
||||
import utils.icons as icons
|
||||
|
||||
|
||||
class Reminder:
|
||||
"""
|
||||
Represents a single reminder with its timer and metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, reminder_id: int, message: str, target_time: datetime, timer: threading.Timer):
|
||||
self.id = reminder_id
|
||||
self.message = message
|
||||
self.target_time = target_time
|
||||
self.timer = timer
|
||||
self.created_time = datetime.now()
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel this reminder."""
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
|
||||
def get_time_remaining(self) -> str:
|
||||
"""Get formatted time remaining until reminder."""
|
||||
now = datetime.now()
|
||||
if self.target_time <= now:
|
||||
return "Overdue"
|
||||
|
||||
delta = self.target_time - now
|
||||
total_seconds = int(delta.total_seconds())
|
||||
|
||||
if total_seconds < 60:
|
||||
return f"{total_seconds}s"
|
||||
elif total_seconds < 3600:
|
||||
minutes = total_seconds // 60
|
||||
seconds = total_seconds % 60
|
||||
return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
|
||||
else:
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
|
||||
|
||||
def get_target_time_str(self) -> str:
|
||||
"""Get formatted target time."""
|
||||
return self.target_time.strftime("%H:%M")
|
||||
|
||||
|
||||
class RemindersPlugin(PluginBase):
|
||||
"""
|
||||
Time-based reminders plugin for the launcher.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.display_name = "Reminders"
|
||||
self.description = "Set time-based reminders with notifications"
|
||||
self.reminders: Dict[int, Reminder] = {}
|
||||
self.next_id = 1
|
||||
|
||||
# Regex patterns for time parsing
|
||||
self.time_patterns = {
|
||||
'relative_time': re.compile(r'^(\d+)([smhd])$'), # 5m, 30s, 2h, 1d
|
||||
'absolute_time': re.compile(r'^(\d{1,2}):(\d{2})$'), # 14:30, 9:15
|
||||
'relative_with_unit': re.compile(r'^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$', re.IGNORECASE)
|
||||
}
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the reminders plugin."""
|
||||
self.set_triggers(["remind", "remind "])
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the reminders plugin."""
|
||||
# Cancel all active reminders
|
||||
for reminder in self.reminders.values():
|
||||
reminder.cancel()
|
||||
self.reminders.clear()
|
||||
|
||||
def _send_notification(self, title: str, message: str):
|
||||
"""Send a desktop notification using notify-send."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message],
|
||||
check=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send notification: {e}")
|
||||
|
||||
def _parse_time_input(self, time_str: str) -> Optional[datetime]:
|
||||
"""
|
||||
Parse various time input formats and return target datetime.
|
||||
|
||||
Supported formats:
|
||||
- 5m, 30s, 2h, 1d (relative time)
|
||||
- 14:30, 9:15 (absolute time today)
|
||||
- 5 minutes, 2 hours (relative with full unit names)
|
||||
"""
|
||||
time_str = time_str.strip().lower()
|
||||
|
||||
# Try relative time format (5m, 30s, 2h, 1d)
|
||||
match = self.time_patterns['relative_time'].match(time_str)
|
||||
if match:
|
||||
value, unit = match.groups()
|
||||
value = int(value)
|
||||
|
||||
if unit == 's':
|
||||
delta = timedelta(seconds=value)
|
||||
elif unit == 'm':
|
||||
delta = timedelta(minutes=value)
|
||||
elif unit == 'h':
|
||||
delta = timedelta(hours=value)
|
||||
elif unit == 'd':
|
||||
delta = timedelta(days=value)
|
||||
else:
|
||||
return None
|
||||
|
||||
return datetime.now() + delta
|
||||
|
||||
# Try absolute time format (14:30, 9:15)
|
||||
match = self.time_patterns['absolute_time'].match(time_str)
|
||||
if match:
|
||||
hour, minute = map(int, match.groups())
|
||||
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||||
target = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
# If the time has already passed today, schedule for tomorrow
|
||||
if target <= datetime.now():
|
||||
target += timedelta(days=1)
|
||||
return target
|
||||
|
||||
# Try relative time with full unit names
|
||||
match = self.time_patterns['relative_with_unit'].match(time_str)
|
||||
if match:
|
||||
value, unit = match.groups()
|
||||
value = int(value)
|
||||
unit = unit.lower()
|
||||
|
||||
if unit in ['sec', 'seconds']:
|
||||
delta = timedelta(seconds=value)
|
||||
elif unit in ['min', 'mins', 'minute', 'minutes']:
|
||||
delta = timedelta(minutes=value)
|
||||
elif unit in ['hour', 'hours']:
|
||||
delta = timedelta(hours=value)
|
||||
elif unit in ['day', 'days']:
|
||||
delta = timedelta(days=value)
|
||||
else:
|
||||
return None
|
||||
|
||||
return datetime.now() + delta
|
||||
|
||||
return None
|
||||
|
||||
def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]:
|
||||
"""Create a new reminder with the given time and message."""
|
||||
target_time = self._parse_time_input(time_str)
|
||||
if not target_time:
|
||||
return None
|
||||
|
||||
# Calculate delay in seconds
|
||||
delay = (target_time - datetime.now()).total_seconds()
|
||||
if delay <= 0:
|
||||
return None
|
||||
|
||||
# Create timer that will trigger the notification
|
||||
timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message])
|
||||
|
||||
# Create reminder object
|
||||
reminder = Reminder(self.next_id, message, target_time, timer)
|
||||
|
||||
# Store reminder and start timer
|
||||
self.reminders[self.next_id] = reminder
|
||||
timer.start()
|
||||
|
||||
# Increment ID for next reminder
|
||||
self.next_id += 1
|
||||
|
||||
return reminder
|
||||
|
||||
def _trigger_reminder(self, reminder_id: int, message: str):
|
||||
"""Trigger a reminder notification and remove it from active reminders."""
|
||||
# Send notification
|
||||
self._send_notification("⏰ Reminder", message)
|
||||
|
||||
# Remove from active reminders
|
||||
if reminder_id in self.reminders:
|
||||
del self.reminders[reminder_id]
|
||||
|
||||
def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int:
|
||||
"""Cancel a specific reminder or all reminders. Returns number of cancelled reminders."""
|
||||
if reminder_id is not None:
|
||||
if reminder_id in self.reminders:
|
||||
self.reminders[reminder_id].cancel()
|
||||
del self.reminders[reminder_id]
|
||||
return 1
|
||||
return 0
|
||||
else:
|
||||
# Cancel all reminders
|
||||
count = len(self.reminders)
|
||||
for reminder in self.reminders.values():
|
||||
reminder.cancel()
|
||||
self.reminders.clear()
|
||||
return count
|
||||
|
||||
def _format_time_remaining(self, total_seconds: float) -> str:
|
||||
"""Format time remaining in a human-readable way."""
|
||||
total_seconds = int(total_seconds)
|
||||
|
||||
if total_seconds < 60:
|
||||
return f"{total_seconds}s"
|
||||
elif total_seconds < 3600:
|
||||
minutes = total_seconds // 60
|
||||
seconds = total_seconds % 60
|
||||
return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
|
||||
else:
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
|
||||
|
||||
def _create_and_confirm_reminder(self, time_str: str, message: str):
|
||||
"""Actually create the reminder when the user presses Enter."""
|
||||
reminder = self._create_reminder(time_str, message)
|
||||
if reminder:
|
||||
time_remaining = reminder.get_time_remaining()
|
||||
self._send_notification("✅ Reminder Created", f"Reminder set for {time_remaining}: {message}")
|
||||
else:
|
||||
self._send_notification("❌ Failed to Create Reminder", "The specified time may be in the past")
|
||||
|
||||
def query(self, query_string: str) -> List[Result]:
|
||||
"""Process reminder queries."""
|
||||
results = []
|
||||
query = query_string.strip()
|
||||
|
||||
if not query:
|
||||
# Show help and active reminders count
|
||||
active_count = len(self.reminders)
|
||||
results.append(
|
||||
Result(
|
||||
title="Reminders Help",
|
||||
subtitle=f"Active reminders: {active_count} | Usage: remind 5m Take a break",
|
||||
icon_markup=icons.timer_on,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "help"},
|
||||
)
|
||||
)
|
||||
|
||||
# Show quick examples
|
||||
examples = [
|
||||
("remind 5m Take a break", "Set 5 minute reminder"),
|
||||
("remind 14:30 Meeting", "Set reminder for 2:30 PM"),
|
||||
("remind list", "List active reminders"),
|
||||
("remind cancel", "Cancel all reminders"),
|
||||
]
|
||||
|
||||
for example, desc in examples:
|
||||
results.append(
|
||||
Result(
|
||||
title=example,
|
||||
subtitle=desc,
|
||||
icon_markup=icons.timer_on,
|
||||
action=lambda: None,
|
||||
relevance=0.8,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "example"},
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# Handle list command
|
||||
if query.lower() in ['list', 'ls', 'show']:
|
||||
if not self.reminders:
|
||||
results.append(
|
||||
Result(
|
||||
title="No Active Reminders",
|
||||
subtitle="Use 'remind 5m message' to set a reminder",
|
||||
icon_markup=icons.timer_off,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "empty_list"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
for reminder in sorted(self.reminders.values(), key=lambda r: r.target_time):
|
||||
time_remaining = reminder.get_time_remaining()
|
||||
target_time = reminder.get_target_time_str()
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title=f"#{reminder.id}: {reminder.message}",
|
||||
subtitle=f"In {time_remaining} (at {target_time})",
|
||||
icon_markup=icons.timer_on,
|
||||
action=lambda rid=reminder.id: self._cancel_reminder(rid),
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "active_reminder", "id": reminder.id},
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# Handle cancel command
|
||||
if query.lower().startswith('cancel') or query.lower().startswith('stop'):
|
||||
parts = query.split()
|
||||
if len(parts) == 1:
|
||||
# Cancel all reminders
|
||||
count = self._cancel_reminder()
|
||||
results.append(
|
||||
Result(
|
||||
title=f"Cancelled {count} Reminders",
|
||||
subtitle="All active reminders have been cancelled",
|
||||
icon_markup=icons.timer_off,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "cancel_all"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Try to cancel specific reminder by ID
|
||||
try:
|
||||
reminder_id = int(parts[1])
|
||||
count = self._cancel_reminder(reminder_id)
|
||||
if count > 0:
|
||||
results.append(
|
||||
Result(
|
||||
title=f"Cancelled Reminder #{reminder_id}",
|
||||
subtitle="Reminder has been cancelled",
|
||||
icon_markup=icons.timer_off,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "cancel_specific"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
Result(
|
||||
title="Reminder Not Found",
|
||||
subtitle=f"No reminder with ID #{reminder_id}",
|
||||
icon_markup=icons.alert,
|
||||
action=lambda: None,
|
||||
relevance=0.5,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "error"},
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
results.append(
|
||||
Result(
|
||||
title="Invalid Reminder ID",
|
||||
subtitle="Please provide a valid reminder ID number",
|
||||
icon_markup=icons.alert,
|
||||
action=lambda: None,
|
||||
relevance=0.5,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "error"},
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# Handle setting new reminders
|
||||
parts = query.split(None, 1)
|
||||
if len(parts) >= 1:
|
||||
time_str = parts[0]
|
||||
message = parts[1] if len(parts) > 1 else "Reminder"
|
||||
|
||||
# Try to parse the time (but don't create the reminder yet!)
|
||||
target_time = self._parse_time_input(time_str)
|
||||
if target_time:
|
||||
# Calculate delay and check if it's valid
|
||||
delay = (target_time - datetime.now()).total_seconds()
|
||||
if delay > 0:
|
||||
# Show what would happen, but don't create the reminder yet
|
||||
time_remaining = self._format_time_remaining(delay)
|
||||
target_time_str = target_time.strftime("%H:%M")
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title=f"Set Reminder: {message}",
|
||||
subtitle=f"Will remind in {time_remaining} (at {target_time_str})",
|
||||
icon_markup=icons.timer_on,
|
||||
action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder(ts, msg),
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "preview", "time_str": time_str, "message": message},
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
Result(
|
||||
title="Time is in the Past",
|
||||
subtitle="Please specify a future time",
|
||||
icon_markup=icons.alert,
|
||||
action=lambda: None,
|
||||
relevance=0.5,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "error"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Invalid time format
|
||||
results.append(
|
||||
Result(
|
||||
title="Invalid Time Format",
|
||||
subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'",
|
||||
icon_markup=icons.alert,
|
||||
action=lambda: None,
|
||||
relevance=0.5,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "error"},
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
Reference in new issue
Block a user