Add Reminders (CalDAV), Notes, Reading List agents + Mac bridge
This commit is contained in:
@@ -44,6 +44,43 @@ def collect_sections(config):
|
||||
print(f" Calendar failed: {e}", file=sys.stderr)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user