Add Reminders (CalDAV), Notes, Reading List agents + Mac bridge

This commit is contained in:
2026-04-13 03:20:44 +00:00
parent 852d329379
commit 8cf5bb51ee
4 changed files with 410 additions and 0 deletions
+37
View File
@@ -44,6 +44,43 @@ def collect_sections(config):
print(f" Calendar failed: {e}", file=sys.stderr) print(f" Calendar failed: {e}", file=sys.stderr)
sections.append(("Calendar", "## Calendar\n\n*Calendar data unavailable.*\n", f"error: {e}")) sections.append(("Calendar", "## Calendar\n\n*Calendar data unavailable.*\n", f"error: {e}"))
# --- Reminders (CalDAV VTODO) ---
reminder_sources = config.get("reminder_sources", [])
reminder_mode = config.get("reminder_mode", "due_today_3days")
if reminder_sources:
try:
from reminders_agent import run as reminders_run
md, summary = reminders_run(reminders_config=reminder_sources, mode=reminder_mode)
sections.append(("Reminders", md, summary))
print(f" Reminders: {summary}")
except Exception as e:
print(f" Reminders failed: {e}", file=sys.stderr)
sections.append(("Reminders", "## Reminders\n\n*Unavailable.*\n", f"error: {e}"))
# --- Notes (via Mac bridge) ---
notes_config = config.get("notes", {})
if notes_config.get("enabled", False):
try:
from notes_agent import run as notes_run
md, summary = notes_run(config=notes_config)
sections.append(("Notes", md, summary))
print(f" Notes: {summary}")
except Exception as e:
print(f" Notes failed: {e}", file=sys.stderr)
sections.append(("Notes", "## Recent Notes\n\n*Unavailable.*\n", f"error: {e}"))
# --- Reading List (via Mac bridge) ---
reading_config = config.get("reading_list", {})
if reading_config.get("enabled", False):
try:
from reading_list_agent import run as reading_list_run
md, summary = reading_list_run(config=reading_config)
sections.append(("Reading List", md, summary))
print(f" Reading List: {summary}")
except Exception as e:
print(f" Reading List failed: {e}", file=sys.stderr)
sections.append(("Reading List", "## Reading List\n\n*Unavailable.*\n", f"error: {e}"))
return sections return sections
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Notes Agent
Fetches recent note titles from Apple Notes via the Mac bridge.
Returns a markdown section for the daily briefing.
Privacy: titles and dates only, no body content.
"""
import sys
from shared import api_request, log_run
AGENT_ID = "notes"
DEFAULT_BRIDGE_URL = "http://192.168.1.62:8551"
def run(config=None):
"""Run the notes agent.
Config keys:
bridge_url: Mac bridge URL (default: http://192.168.1.62:8551)
limit: max notes to show (default: 10)
folder: optional folder filter
"""
cfg = config or {}
bridge_url = cfg.get("bridge_url", DEFAULT_BRIDGE_URL)
limit = cfg.get("limit", 10)
folder = cfg.get("folder")
try:
params = f"?limit={limit}"
if folder:
params += f"&folder={folder}"
data = api_request(f"{bridge_url}/api/notes{params}")
notes = data.get("notes", [])
if not notes:
return "## Recent Notes\n\n*No recent notes.*\n", "No notes"
md = "## Recent Notes\n\n"
md += "| Note | Folder | Modified |\n"
md += "|------|--------|----------|\n"
for n in notes:
mod = n.get("modificationDate", "")[:10]
md += f"| {n['name'][:60]} | {n['folder']} | {mod} |\n"
md += "\n"
summary = f"{len(notes)} recent notes"
log_run(AGENT_ID, "success", output=summary)
return md, summary
except Exception as e:
err = f"Notes error: {e}"
print(f" {err}", file=sys.stderr)
log_run(AGENT_ID, "failed", err=err)
return "## Recent Notes\n\n*Could not reach Apple Bridge. Is your Mac online?*\n", f"error: {e}"
if __name__ == "__main__":
section, summary = run()
print(section)
print(f"\nSummary: {summary}")
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Reading List Agent
Fetches Safari "Saved for Later" reading list via the Mac bridge.
Returns a markdown section for the daily briefing.
"""
import sys
from shared import api_request, log_run
AGENT_ID = "reading-list"
DEFAULT_BRIDGE_URL = "http://192.168.1.62:8551"
def run(config=None):
"""Run the reading list agent.
Config keys:
bridge_url: Mac bridge URL (default: http://192.168.1.62:8551)
unread_only: only show unread items (default: True)
limit: max items to show (default: 20)
"""
cfg = config or {}
bridge_url = cfg.get("bridge_url", DEFAULT_BRIDGE_URL)
unread_only = cfg.get("unread_only", True)
limit = cfg.get("limit", 20)
try:
data = api_request(f"{bridge_url}/api/safari/reading-list")
items = data.get("items", [])
if unread_only:
items = [i for i in items if i.get("isUnread", True)]
items = items[:limit]
if not items:
return "## Reading List\n\n*No saved articles.*\n", "No articles"
md = "## Reading List\n\n"
md += "| Article | Saved |\n"
md += "|---------|-------|\n"
for item in items:
title = item.get("title", "Untitled")[:70]
url = item.get("url", "")
date_added = item.get("dateAdded", "")[:10]
# Link the title if we have a URL
display = f"[{title}]({url})" if url else title
md += f"| {display} | {date_added} |\n"
md += "\n"
summary = f"{len(items)} saved article{'s' if len(items) != 1 else ''}"
log_run(AGENT_ID, "success", output=summary)
return md, summary
except Exception as e:
err = f"Reading list error: {e}"
print(f" {err}", file=sys.stderr)
log_run(AGENT_ID, "failed", err=err)
return "## Reading List\n\n*Could not reach Apple Bridge. Is your Mac online?*\n", f"error: {e}"
if __name__ == "__main__":
section, summary = run()
print(section)
print(f"\nSummary: {summary}")
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Reminders Agent
Fetches reminders (VTODO) from CalDAV servers (iCloud, Google, etc.)
Returns a markdown section for the daily briefing.
"""
import sys
from datetime import datetime, timedelta, date
from shared import MT, log_run
AGENT_ID = "reminders"
# Display mode options (configurable per user in GUI)
MODE_DUE_TODAY = "due_today_overdue"
MODE_DUE_3DAYS = "due_today_3days"
MODE_ALL_INCOMPLETE = "all_incomplete"
def fetch_reminders(source):
"""Fetch incomplete VTODO items from a CalDAV server."""
import caldav
todos = []
try:
client = caldav.DAVClient(
url=source["url"],
username=source.get("username", ""),
password=source.get("password", ""),
)
principal = client.principal()
calendars = principal.calendars()
for cal in calendars:
try:
cal_todos = cal.todos(include_completed=False)
for todo in cal_todos:
parsed = parse_vtodo(todo, source["name"], cal.name)
if parsed:
todos.append(parsed)
except Exception as e:
# Some calendars don't support VTODO
pass
except Exception as e:
print(f" CalDAV error for {source['name']}: {e}", file=sys.stderr)
return todos
def parse_vtodo(todo, source_name, list_name):
"""Parse a CalDAV VTODO into a dict."""
from icalendar import Calendar
try:
cal = Calendar.from_ical(todo.data)
for component in cal.walk():
if component.name != "VTODO":
continue
summary = str(component.get("SUMMARY", "Untitled"))
description = str(component.get("DESCRIPTION", "")) if component.get("DESCRIPTION") else ""
priority_val = component.get("PRIORITY")
priority = int(str(priority_val)) if priority_val else 0
status = str(component.get("STATUS", "NEEDS-ACTION"))
url = str(component.get("URL", "")) if component.get("URL") else ""
# Due date
due = component.get("DUE")
due_dt = None
all_day_due = False
if due:
dt = due.dt if hasattr(due, "dt") else due
if isinstance(dt, date) and not isinstance(dt, datetime):
due_dt = datetime.combine(dt, datetime.min.time()).replace(tzinfo=MT)
all_day_due = True
else:
due_dt = dt.astimezone(MT) if dt.tzinfo else dt.replace(tzinfo=MT)
# Recurring
rrule = component.get("RRULE")
is_recurring = rrule is not None
return {
"summary": summary,
"list": list_name,
"source": source_name,
"due": due_dt,
"all_day_due": all_day_due,
"priority": priority,
"description": description[:200] if description else "",
"url": url,
"recurring": is_recurring,
"status": status,
}
except Exception:
pass
return None
def filter_reminders(todos, mode):
"""Filter reminders based on display mode."""
now = datetime.now(MT)
today = now.date()
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()).replace(tzinfo=MT)
three_days = datetime.combine(today + timedelta(days=3), datetime.min.time()).replace(tzinfo=MT)
if mode == MODE_ALL_INCOMPLETE:
return todos
filtered = []
for t in todos:
if t["due"] is None:
# No due date — include in "all incomplete" only
if mode == MODE_ALL_INCOMPLETE:
filtered.append(t)
continue
if t["due"] < now:
# Overdue — always include
t["_overdue"] = True
filtered.append(t)
elif mode == MODE_DUE_TODAY and t["due"] < today_end:
filtered.append(t)
elif mode == MODE_DUE_3DAYS and t["due"] < three_days:
filtered.append(t)
return filtered
def format_section(todos, mode):
"""Format reminders into a markdown section."""
now = datetime.now(MT)
today = now.date()
md = "## Reminders\n\n"
if not todos:
if mode == MODE_DUE_TODAY:
md += "*No reminders due today or overdue.*\n"
elif mode == MODE_DUE_3DAYS:
md += "*No reminders due in the next 3 days.*\n"
else:
md += "*No incomplete reminders.*\n"
return md, "No reminders"
# Separate overdue from upcoming
overdue = [t for t in todos if t.get("_overdue")]
upcoming = [t for t in todos if not t.get("_overdue")]
# Sort
overdue.sort(key=lambda t: t["due"] or datetime.min.replace(tzinfo=MT))
upcoming.sort(key=lambda t: t["due"] or datetime.max.replace(tzinfo=MT))
if overdue:
md += "### Overdue\n\n"
md += "| Reminder | List | Due | Priority |\n"
md += "|----------|------|-----|----------|\n"
for t in overdue:
due_str = t["due"].strftime("%b %d") if t["due"] else "-"
pri = ["", "High", "Medium", "", "", "", "", "", "", "Low"][min(t["priority"], 9)] if t["priority"] else ""
recurring = " (recurring)" if t["recurring"] else ""
md += f"| {t['summary']}{recurring} | {t['list']} | {due_str} | {pri} |\n"
md += "\n"
if upcoming:
md += "### Upcoming\n\n"
md += "| Reminder | List | Due | Priority |\n"
md += "|----------|------|-----|----------|\n"
for t in upcoming:
if t["due"]:
if t["due"].date() == today:
due_str = "Today" + ("" if t["all_day_due"] else f" {t['due'].strftime('%-I:%M %p')}")
else:
due_str = t["due"].strftime("%b %d")
if not t["all_day_due"]:
due_str += f" {t['due'].strftime('%-I:%M %p')}"
else:
due_str = "No date"
pri = ["", "High", "Medium", "", "", "", "", "", "", "Low"][min(t["priority"], 9)] if t["priority"] else ""
recurring = " (recurring)" if t["recurring"] else ""
md += f"| {t['summary']}{recurring} | {t['list']} | {due_str} | {pri} |\n"
md += "\n"
overdue_count = len(overdue)
upcoming_count = len(upcoming)
parts = []
if overdue_count:
parts.append(f"{overdue_count} overdue")
if upcoming_count:
parts.append(f"{upcoming_count} upcoming")
summary = ", ".join(parts) if parts else "No reminders"
return md, summary
def run(reminders_config=None, mode=MODE_DUE_3DAYS):
"""Run the reminders agent.
Args:
reminders_config: list of CalDAV source dicts [{name, url, username, password}]
mode: display mode (due_today_overdue, due_today_3days, all_incomplete)
"""
sources = reminders_config or []
if not sources:
return "## Reminders\n\n*No reminder sources configured. Add CalDAV credentials in Settings.*\n", "Not configured"
all_todos = []
for source in sources:
print(f" Fetching reminders: {source.get('name', '?')}...")
todos = fetch_reminders(source)
print(f" Got {len(todos)} incomplete reminders from {source.get('name', '?')}")
all_todos.extend(todos)
filtered = filter_reminders(all_todos, mode)
section, summary = format_section(filtered, mode)
log_run(AGENT_ID, "success", output=summary, metadata={
"sources": len(sources),
"total_incomplete": len(all_todos),
"filtered": len(filtered),
"mode": mode,
})
return section, summary
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--url", required=True, help="CalDAV URL")
parser.add_argument("--user", required=True, help="Username")
parser.add_argument("--password", required=True, help="Password")
parser.add_argument("--name", default="Test", help="Source name")
parser.add_argument("--mode", default=MODE_DUE_3DAYS, choices=[MODE_DUE_TODAY, MODE_DUE_3DAYS, MODE_ALL_INCOMPLETE])
args = parser.parse_args()
section, summary = run(
[{"name": args.name, "url": args.url, "username": args.user, "password": args.password}],
mode=args.mode,
)
print(section)
print(f"\nSummary: {summary}")