import { existsSync } from "node:fs"; import ical from "node-ical"; import type { SourceModule } from "../registry"; import { runOsa, runCmd, parseRows, osaQuote } from "@/lib/mac"; import { CalendarConfig, CalendarPayload, type CalendarConfig as Config, type CalendarPayload as Payload, } from "@/lib/schemas/sources/calendar"; function icsUrls(config: Config, env: NodeJS.ProcessEnv): string[] { if (config.urls.length) return config.urls; return (env.CALENDAR_ICS_URLS ?? "").split(",").map((s) => s.trim()).filter(Boolean); } // Local Calendar.app events. Prefer icalBuddy (reads EventKit directly — fast, // even on large/subscribed calendars); fall back to Calendar.app AppleScript, // whose `whose`-clause query is slow and times out on big calendars. async function fetchLocal(config: Config): Promise { const names = config.calendars; if (names.length === 0) throw new Error("Pick at least one calendar in Settings."); const bin = ["/opt/homebrew/bin/icalBuddy", "/usr/local/bin/icalBuddy"].find(existsSync); return bin ? fetchLocalIcalBuddy(bin, config) : fetchLocalAppleScript(config); } // icalBuddy output: one event per line, fields joined by ~FS~ and each line // prefixed with ~RS~ (so a location that wraps onto extra lines stays in-record). // ~RS~Title~FS~2026-06-06 at 09:00 - 10:00~FS~location: 123 Main St // All-day events have no " at " and may span dates (2026-07-23 - 2026-07-25). async function fetchLocalIcalBuddy(bin: string, config: Config): Promise { const args = [ "-nc", // no calendar names "-nrd", // no relative dates (today/tomorrow) — emit real dates "-b", "~RS~", "-ps", "|~FS~|", "-iep", "title,datetime,location", "-po", "title,datetime,location", "-df", "%Y-%m-%d", "-tf", "%H:%M", "-ic", config.calendars.join(","), `eventsToday+${config.daysAhead}`, ]; const out = await runCmd(bin, args, 30_000); const events = out .split("~RS~") .map((chunk) => chunk.trim()) .filter(Boolean) .map((chunk) => { const parts = chunk.split("~FS~"); const summary = (parts[0] ?? "").trim() || "(no title)"; const dt = (parts[1] ?? "").trim(); const location = parts.slice(2).join(", ").replace(/^location:\s*/i, "").replace(/\s*\n\s*/g, ", ").trim() || null; const allDay = !dt.includes(" at "); const date = dt.match(/\d{4}-\d{2}-\d{2}/)?.[0]; const time = allDay ? "00:00" : (dt.match(/\d{2}:\d{2}/)?.[0] ?? "00:00"); const start = date ? new Date(`${date}T${time}`) : new Date(NaN); return { summary, start: isNaN(+start) ? new Date().toISOString() : start.toISOString(), end: null, location, calendar: null, allDay, }; }); events.sort((a, b) => +new Date(a.start) - +new Date(b.start)); return { events: events.slice(0, 30) }; } async function fetchLocalAppleScript(config: Config): Promise { const names = config.calendars; const list = `{${names.map(osaQuote).join(", ")}}`; const script = [ `set fs to (ASCII character 31)`, `set rs to (ASCII character 30)`, `set d1 to (current date) - (time of (current date))`, `set d2 to d1 + (${config.daysAhead} * days)`, `set calNames to ${list}`, `set output to ""`, `tell application "Calendar"`, ` repeat with cn in calNames`, ` try`, ` set theCal to first calendar whose name is (cn as string)`, ` repeat with e in (every event of theCal whose start date > (d1 - 1) and start date < d2)`, ` set sd to start date of e`, ` set mo to text -2 thru -1 of ("0" & ((month of sd) as integer))`, ` set dy to text -2 thru -1 of ("0" & (day of sd))`, ` set hh to text -2 thru -1 of ("0" & (hours of sd))`, ` set mm to text -2 thru -1 of ("0" & (minutes of sd))`, ` set startStr to ((year of sd) as string) & "-" & mo & "-" & dy & " " & hh & ":" & mm`, ` set ad to "0"`, ` try`, ` if allday event of e then set ad to "1"`, ` end try`, ` set loc to ""`, ` try`, ` if location of e is not missing value then set loc to location of e`, ` end try`, ` set output to output & (summary of e) & fs & startStr & fs & (cn as string) & fs & ad & fs & loc & rs`, ` end repeat`, ` end try`, ` end repeat`, `end tell`, `return output`, ].join("\n"); const rows = parseRows(await runOsa(script, 60_000)); const events = rows.map(([summary, startStr, cal, ad, loc]) => { const allDay = ad === "1"; const start = new Date((startStr ?? "").replace(" ", "T")); return { summary: summary || "(no title)", start: isNaN(+start) ? new Date().toISOString() : start.toISOString(), end: null, location: loc || null, calendar: cal || null, allDay, }; }); events.sort((a, b) => +new Date(a.start) - +new Date(b.start)); return { events }; } async function fetchIcs(config: Config, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { const urls = icsUrls(config, env); if (urls.length === 0) throw new Error("Add an ICS URL in Settings or CALENDAR_ICS_URLS."); const now = new Date(); const horizon = new Date(now.getTime() + config.daysAhead * 86_400_000); const events: Payload["events"] = []; await Promise.all( urls.map(async (url) => { const res = await fetch(url, { signal }); if (!res.ok) return; const parsed = ical.sync.parseICS(await res.text()); for (const item of Object.values(parsed)) { if (item.type !== "VEVENT") continue; const ev = item as ical.VEvent & { rrule?: { between(a: Date, b: Date): Date[] } }; const allDay = (ev as { datetype?: string }).datetype === "date"; const durationMs = ev.end && ev.start ? +ev.end - +ev.start : 0; const starts: Date[] = ev.rrule ? ev.rrule.between(now, horizon) : ev.start && +ev.start >= +now && +ev.start <= +horizon ? [ev.start] : []; for (const start of starts) { events.push({ summary: ev.summary ?? "(no title)", start: start.toISOString(), end: durationMs ? new Date(+start + durationMs).toISOString() : null, location: ev.location || null, calendar: null, allDay, }); } } }), ); events.sort((a, b) => +new Date(a.start) - +new Date(b.start)); return { events: events.slice(0, 30) }; } export const calendarModule: SourceModule = { kind: "calendar", label: "Calendar", keyless: true, // local mode needs no key (just Automation permission) defaultRefreshSeconds: 1800, configSchema: CalendarConfig, payloadSchema: CalendarPayload, isConfigured: (config, env) => config.mode === "local" ? config.calendars.length > 0 : icsUrls(config, env).length > 0, async fetch({ config, env, signal }) { return config.mode === "local" ? fetchLocal(config) : fetchIcs(config, env, signal); }, };