diff --git a/agents/daily_briefing.py b/agents/daily_briefing.py index cfb7fbc..930788b 100644 --- a/agents/daily_briefing.py +++ b/agents/daily_briefing.py @@ -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 diff --git a/agents/notes_agent.py b/agents/notes_agent.py new file mode 100644 index 0000000..d0504b1 --- /dev/null +++ b/agents/notes_agent.py @@ -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}") diff --git a/agents/reading_list_agent.py b/agents/reading_list_agent.py new file mode 100644 index 0000000..818f9bd --- /dev/null +++ b/agents/reading_list_agent.py @@ -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}") diff --git a/agents/reminders_agent.py b/agents/reminders_agent.py new file mode 100644 index 0000000..9a363a7 --- /dev/null +++ b/agents/reminders_agent.py @@ -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}")