1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
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<Payload> {
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<Payload> {
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<Payload> {
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<Payload> {
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<Config, Payload> = {
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);
},
};
|