From acfb530c40569a6123d878ec3e724e7a6c6e14fb Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Wed, 18 Jun 2025 22:46:54 +0545 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(launcher,=20plugins):=20add=20?= =?UTF-8?q?reminders=20plugin=20with=20time-based=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/launcher.json | 13 + modules/launcher/plugin_manager.py | 2 + modules/launcher/plugins/reminders.py | 427 ++++++++++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 modules/launcher/plugins/reminders.py diff --git a/config/launcher.json b/config/launcher.json index 9b27e12..f37b693 100644 --- a/config/launcher.json +++ b/config/launcher.json @@ -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": { diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py index 788379f..c6bf040 100644 --- a/modules/launcher/plugin_manager.py +++ b/modules/launcher/plugin_manager.py @@ -94,6 +94,8 @@ class PluginManager: "pomodoro", "websearch", "kanban", + "calendar", + "reminders", ] for plugin_name in default_plugins: diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py new file mode 100644 index 0000000..c5bf9c7 --- /dev/null +++ b/modules/launcher/plugins/reminders.py @@ -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