Files
Modus/modules/launcher/plugins/reminders.py
T

449 lines
17 KiB
Python

import re
import subprocess
import threading
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
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"])
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_name="alarm-timer",
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_name="alarm-timer",
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_name="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_name="alarm-timer",
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_name="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_name="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_name="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_name="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_name="alarm-timer",
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_name="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_name="alert",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "error"},
)
)
return results