@mgcrea/mcp-apple-calendar 0.0.0-bootstrap

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2337 @@
1
+ import { AppBusyError as CalendarBusyError, AppNotRunningError as CalendarNotRunningError, AppleAutomationError, AppleAutomationError as AppleCalendarError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, readPackageIdentity, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
2
+ import { readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ //#region src/build-info.ts
8
+ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
9
+ name: "@mgcrea/mcp-apple-calendar",
10
+ version: "0.0.0"
11
+ });
12
+ const BUILD_INFO = {
13
+ name: pkg.name,
14
+ version: pkg.version,
15
+ gitCommit: "df06e7f",
16
+ gitCommitDate: "2026-08-22T16:20:23+02:00"
17
+ };
18
+ //#endregion
19
+ //#region src/client/errors.ts
20
+ /**
21
+ * Calendar's error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`;
22
+ * what belongs here is the identity those messages are written against, and the
23
+ * errors that are genuinely about calendars.
24
+ */
25
+ /**
26
+ * Named in every user-facing error and in the env vars they mention.
27
+ *
28
+ * `appName` is the display name, which is NOT the bundle id: Calendar.app is
29
+ * still `com.apple.iCal` underneath. Every other surface has the two agreeing,
30
+ * so the mismatch is written down in both places it matters.
31
+ */
32
+ const CALENDAR_SURFACE = {
33
+ appName: "Calendar",
34
+ envPrefix: "APPLE_CALENDAR"
35
+ };
36
+ /** Calendar's Apple Events target. Not `com.apple.Calendar`, which does not exist. */
37
+ const CALENDAR_BUNDLE_ID = "com.apple.iCal";
38
+ /**
39
+ * A date argument could not be understood.
40
+ *
41
+ * Carries the accepted grammar rather than just rejecting, because the caller is
42
+ * usually a model that will retry once and needs to know what shape to retry in.
43
+ */
44
+ var InvalidDateError = class extends AppleAutomationError {
45
+ name = "InvalidDateError";
46
+ constructor(field, raw, reason) {
47
+ super(`Could not read ${field} from ${JSON.stringify(raw)}: ${reason}. Accepted: an ISO-8601 date "2026-08-20" (a whole day) or date-time "2026-08-20T09:00" (an instant), or a relative offset like "+2d", "+3h", "+45m", "+1w", "today", "tomorrow", "tomorrow 09:00", or "next monday".`, {
48
+ field,
49
+ raw
50
+ });
51
+ }
52
+ };
53
+ /** A CalendarRef no longer resolves — deleted, or moved to another calendar. */
54
+ var EventNotFoundError = class extends AppleAutomationError {
55
+ name = "EventNotFoundError";
56
+ constructor(ref) {
57
+ super(`No event for ref "${ref}". It was probably deleted or moved since the search ran. Re-run the search to get a current ref.`, { ref });
58
+ }
59
+ };
60
+ /** A calendar was named that Calendar does not have. */
61
+ var CalendarNotFoundError = class extends AppleAutomationError {
62
+ name = "CalendarNotFoundError";
63
+ constructor(name, available = []) {
64
+ super(`No calendar named "${name}".` + (available.length ? ` Available: ${available.slice(0, 20).join(", ")}${available.length > 20 ? ", …" : ""}.` : ` Use apple_calendar_list_calendars to see what exists.`), { requested: name });
65
+ }
66
+ };
67
+ /**
68
+ * A write was aimed at a calendar that cannot accept one.
69
+ *
70
+ * Its own error rather than a generic failure because the cause is almost
71
+ * always structural rather than a mistake: holiday, birthday and subscribed
72
+ * calendars are read-only by nature, and a caller that hits one needs to pick a
73
+ * different target, not retry.
74
+ */
75
+ var CalendarNotWritableError = class extends AppleAutomationError {
76
+ name = "CalendarNotWritableError";
77
+ constructor(name) {
78
+ super(`The calendar "${name}" is read-only, so nothing can be written to it. Subscribed calendars — holidays, birthdays, and anything added by URL — are read-only by nature. Use apple_calendar_list_calendars to find one where "writable" is true.`, { calendar: name });
79
+ }
80
+ };
81
+ //#endregion
82
+ //#region src/client/dates.ts
83
+ /**
84
+ * Date handling for the Calendar tools.
85
+ *
86
+ * ## Two halves
87
+ *
88
+ * INPUT is the grammar `packages/reminders/src/client/dates.ts` established, and
89
+ * it is deliberately identical: a caller writing `2026-08-20` names a day, one
90
+ * writing `2026-08-20T09:00` names an instant, `+2d` names a duration, and day
91
+ * and week offsets are calendar arithmetic while hour and minute offsets are
92
+ * elapsed time. That split is not cosmetic — getting it backwards drifts every
93
+ * date by an hour twice a year.
94
+ *
95
+ * OUTPUT is Calendar's own problem and has no analogue in Reminders. An event
96
+ * carries a start AND an end, a timezone of its own, and an all-day flag that
97
+ * changes what the stored number even means. Rendering that wrongly is the
98
+ * quietest bug on this surface, so `EventInstant` makes the caller's two cases
99
+ * two different shapes rather than one shape with a boolean to remember.
100
+ *
101
+ * ## The timezone rules, measured rather than assumed
102
+ *
103
+ * `docs/calendar.md` records 8 distinct `start_tz` values across 1,350 rows,
104
+ * with no nulls, of which two are not IANA names and mean OPPOSITE things:
105
+ *
106
+ * `_float` a floating date — an instant deliberately without a zone
107
+ * `GMT+0200` a perfectly definite fixed offset that is merely not IANA
108
+ *
109
+ * Collapsing the second into the first silently discards two hours, so they are
110
+ * classified apart here. Anything matching `GMT±HHMM` is honoured as an offset;
111
+ * only what is left over floats.
112
+ */
113
+ const DAY_NAMES = [
114
+ "sunday",
115
+ "monday",
116
+ "tuesday",
117
+ "wednesday",
118
+ "thursday",
119
+ "friday",
120
+ "saturday"
121
+ ];
122
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
123
+ const ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
124
+ const OFFSET = /^\+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$/;
125
+ const DAY_WORD = /^(today|tomorrow)(?:\s+(\d{1,2}):(\d{2}))?$/;
126
+ const NEXT_DAY = /^next\s+([a-z]+)(?:\s+(\d{1,2}):(\d{2}))?$/;
127
+ const DURATION = /^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours)?$/;
128
+ /** `GMT+0200`, `UTC-05:00`. A definite zone that is simply not an IANA name. */
129
+ const FIXED_OFFSET = /^(?:GMT|UTC)([+-])(\d{2}):?(\d{2})$/i;
130
+ const pad$1 = (n, w = 2) => String(n).padStart(w, "0");
131
+ /** `+02:00` / `-05:00` / `Z` for a given instant, in the system zone. */
132
+ const offsetOf = (d) => {
133
+ const mins = -d.getTimezoneOffset();
134
+ if (mins === 0) return "Z";
135
+ const sign = mins < 0 ? "-" : "+";
136
+ const abs = Math.abs(mins);
137
+ return `${sign}${pad$1(Math.floor(abs / 60))}:${pad$1(abs % 60)}`;
138
+ };
139
+ /**
140
+ * Local wall-clock time rendered with its offset.
141
+ *
142
+ * Deliberately not `toISOString()`, which converts to UTC and would report a
143
+ * 09:00 meeting as `07:00Z` — correct as an instant, unreadable in a result
144
+ * whose purpose is confirming what the caller asked for.
145
+ */
146
+ const toLocalIso = (d) => `${d.getFullYear()}-${pad$1(d.getMonth() + 1)}-${pad$1(d.getDate())}T${pad$1(d.getHours())}:${pad$1(d.getMinutes())}:${pad$1(d.getSeconds())}${offsetOf(d)}`;
147
+ const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
148
+ const endOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
149
+ /** Calendar-aware day arithmetic: same wall-clock time, n days later. */
150
+ const addDays = (d, n) => {
151
+ const out = new Date(d.getTime());
152
+ out.setDate(out.getDate() + n);
153
+ return out;
154
+ };
155
+ const at = (day, hours, minutes) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes, 0, 0);
156
+ const result = (kind, when, raw) => ({
157
+ kind,
158
+ at: when,
159
+ iso: toLocalIso(when),
160
+ raw
161
+ });
162
+ /**
163
+ * Parse one date argument.
164
+ *
165
+ * @param field Named in the error, so a failure says WHICH argument was bad.
166
+ * @param raw The caller's string.
167
+ * @param now Injected for hermetic tests.
168
+ */
169
+ const parseDate = (field, raw, now = /* @__PURE__ */ new Date()) => {
170
+ const text = String(raw ?? "").trim();
171
+ if (!text) throw new InvalidDateError(field, String(raw), "it is empty");
172
+ const lower = text.toLowerCase();
173
+ const dt = ISO_DATETIME.exec(text);
174
+ if (dt) {
175
+ const [, y, mo, d, hh, mm, ss, zone] = dt;
176
+ const when = zone ? new Date(text.replace(" ", "T")) : new Date(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss ?? "0"), 0);
177
+ if (Number.isNaN(when.getTime())) throw new InvalidDateError(field, text, "it is not a real date");
178
+ return result("timed", when, text);
179
+ }
180
+ const only = ISO_DATE.exec(text);
181
+ if (only) {
182
+ const [, y, mo, d] = only;
183
+ const when = new Date(Number(y), Number(mo) - 1, Number(d), 0, 0, 0, 0);
184
+ if (Number.isNaN(when.getTime())) throw new InvalidDateError(field, text, "it is not a real date");
185
+ if (when.getMonth() !== Number(mo) - 1 || when.getDate() !== Number(d)) throw new InvalidDateError(field, text, `there is no day ${d} in month ${mo}`);
186
+ return result("allDay", when, text);
187
+ }
188
+ const off = OFFSET.exec(lower);
189
+ if (off) {
190
+ const n = Number(off[1]);
191
+ const unit = String(off[2]);
192
+ if (!Number.isFinite(n)) throw new InvalidDateError(field, text, "the amount is not a number");
193
+ if (unit.startsWith("d")) return result("timed", addDays(now, n), text);
194
+ if (unit.startsWith("w")) return result("timed", addDays(now, n * 7), text);
195
+ const ms = unit.startsWith("h") ? n * 36e5 : n * 6e4;
196
+ return result("timed", new Date(now.getTime() + ms), text);
197
+ }
198
+ const word = DAY_WORD.exec(lower);
199
+ if (word) {
200
+ const day = word[1] === "tomorrow" ? addDays(now, 1) : now;
201
+ if (word[2] === void 0) return result("allDay", startOfDay(day), text);
202
+ const [hh, mm] = [Number(word[2]), Number(word[3])];
203
+ if (hh > 23 || mm > 59) throw new InvalidDateError(field, text, `${hh}:${word[3]} is not a time`);
204
+ return result("timed", at(day, hh, mm), text);
205
+ }
206
+ const next = NEXT_DAY.exec(lower);
207
+ if (next) {
208
+ const idx = DAY_NAMES.findIndex((n) => n === next[1] || n.slice(0, 3) === next[1]);
209
+ if (idx === -1) throw new InvalidDateError(field, text, `"${next[1]}" is not a day of the week`);
210
+ const ahead = (idx - now.getDay() + 7) % 7 || 7;
211
+ const day = addDays(now, ahead);
212
+ if (next[2] === void 0) return result("allDay", startOfDay(day), text);
213
+ const [hh, mm] = [Number(next[2]), Number(next[3])];
214
+ if (hh > 23 || mm > 59) throw new InvalidDateError(field, text, `${hh}:${next[3]} is not a time`);
215
+ return result("timed", at(day, hh, mm), text);
216
+ }
217
+ throw new InvalidDateError(field, text, "it matches none of the accepted forms");
218
+ };
219
+ /**
220
+ * Parse a bound for a range filter.
221
+ *
222
+ * A bare day means the WHOLE day, so which edge it resolves to depends on which
223
+ * side of the range it is. Resolving both to midnight would make an end bound
224
+ * quietly exclude the day the caller named.
225
+ */
226
+ const parseBound = (field, raw, edge, now = /* @__PURE__ */ new Date()) => {
227
+ const parsed = parseDate(field, raw, now);
228
+ if (parsed.kind !== "allDay") return parsed.at;
229
+ return edge === "end" ? endOfDay(parsed.at) : startOfDay(parsed.at);
230
+ };
231
+ /**
232
+ * Resolve a query window.
233
+ *
234
+ * Unlike a note list, a calendar has no natural "everything": it stretches
235
+ * indefinitely in both directions and `MAX(start_date)` on the probed store
236
+ * already reads 2030. So an unbounded default would scan a decade to report
237
+ * next Tuesday, and every window is bounded on both ends.
238
+ */
239
+ const parseRange = (opts, now = /* @__PURE__ */ new Date()) => {
240
+ const from = opts.from ? parseBound("from", opts.from, "start", now) : startOfDay(now);
241
+ const to = opts.to ? parseBound("to", opts.to, "end", now) : endOfDay(addDays(from, opts.defaultRangeDays - 1));
242
+ if (to.getTime() < from.getTime()) throw new InvalidDateError("to", String(opts.to ?? ""), `it resolves to ${toLocalIso(to)}, which is before from (${toLocalIso(from)})`);
243
+ const maxMs = opts.maxRangeDays * 864e5;
244
+ if (to.getTime() - from.getTime() > maxMs) return {
245
+ from,
246
+ to: new Date(from.getTime() + maxMs),
247
+ clamped: true
248
+ };
249
+ return {
250
+ from,
251
+ to,
252
+ clamped: false
253
+ };
254
+ };
255
+ /** `90`, `"90"`, `"90m"`, `"2h"` -> minutes. */
256
+ const parseDuration = (field, raw) => {
257
+ const text = String(raw ?? "").trim().toLowerCase();
258
+ if (!text) throw new InvalidDateError(field, String(raw), "it is empty");
259
+ const m = DURATION.exec(text);
260
+ if (!m) throw new InvalidDateError(field, text, "expected minutes, or a value like \"90m\" or \"2h\"");
261
+ const n = Number(m[1]);
262
+ if (!Number.isFinite(n) || n <= 0) throw new InvalidDateError(field, text, "the amount must be a positive number");
263
+ return m[2]?.startsWith("h") ? n * 60 : n;
264
+ };
265
+ /** Whether a stored `start_tz` names a zone this process can actually resolve. */
266
+ const isIanaZone = (tz) => {
267
+ try {
268
+ Intl.DateTimeFormat("en-US", { timeZone: tz });
269
+ return true;
270
+ } catch {
271
+ return false;
272
+ }
273
+ };
274
+ /**
275
+ * A stored timezone string, classified.
276
+ *
277
+ * `null` means floating, and floating is a real state rather than missing data:
278
+ * the event names a wall-clock time that is correct wherever you open it.
279
+ */
280
+ const resolveZone = (tz) => {
281
+ if (!tz) return null;
282
+ const text = String(tz).trim();
283
+ if (!text || text === "_float") return null;
284
+ if (isIanaZone(text)) return text;
285
+ const fixed = FIXED_OFFSET.exec(text);
286
+ if (fixed) {
287
+ const [, sign, hh, mm] = fixed;
288
+ if (mm === "00") {
289
+ const name = `Etc/GMT${sign === "+" ? "-" : "+"}${Number(hh)}`;
290
+ if (isIanaZone(name)) return name;
291
+ }
292
+ }
293
+ return null;
294
+ };
295
+ const partsIn = (d, tz) => {
296
+ const fmt = Intl.DateTimeFormat("en-US", {
297
+ timeZone: tz,
298
+ hourCycle: "h23",
299
+ year: "numeric",
300
+ month: "2-digit",
301
+ day: "2-digit",
302
+ hour: "2-digit",
303
+ minute: "2-digit",
304
+ second: "2-digit",
305
+ timeZoneName: "longOffset"
306
+ });
307
+ const out = {};
308
+ for (const p of fmt.formatToParts(d)) out[p.type] = p.value;
309
+ return out;
310
+ };
311
+ /**
312
+ * Render a stored instant as an event start or end.
313
+ *
314
+ * ## Why all-day is derived in the event's own zone, falling back to local
315
+ *
316
+ * An all-day event names a DAY, and the day you get depends entirely on which
317
+ * frame you read the stored instant in. MEASURED against a live store:
318
+ *
319
+ * stored 2026-08-20T22:00:00Z
320
+ * UTC getters 2026-08-20 <- a day early
321
+ * local getters 2026-08-21 <- what Calendar.app shows
322
+ *
323
+ * Calendar anchors an all-day event at midnight in the event's own zone, which
324
+ * for a floating date is the local one. So the day comes from local components,
325
+ * or from `start_tz` when that names a real zone.
326
+ *
327
+ * THIS WAS ORIGINALLY WRITTEN THE OTHER WAY ROUND, and the mistake is worth
328
+ * keeping on the record. `docs/reminders.md` documents that REMINDERS' store
329
+ * holds UTC midnight while its Apple Events lane holds local midnight; that was
330
+ * generalised to Calendar without measuring, and the unit tests agreed because
331
+ * their fixtures were built on the same wrong assumption. It survived a
332
+ * four-timezone test matrix and was caught only by reading a real calendar,
333
+ * where the rendered day disagreed with the ref in the same result.
334
+ *
335
+ * The anchor is a property of the store, not a general rule. Measure it per
336
+ * surface. `dates.test.ts` now builds its fixtures at LOCAL midnight, which is
337
+ * what the data actually looks like.
338
+ *
339
+ * @param appleSeconds Core Data seconds, straight from the column.
340
+ * @param tz The row's `start_tz` / `end_tz`, unclassified.
341
+ * @param allDay The row's all-day flag. Authoritative; never inferred.
342
+ * @param epochOffset From `StoreCapabilities`, so the 31-year bug has one home.
343
+ */
344
+ const renderInstant = (appleSeconds, tz, allDay, epochOffset) => {
345
+ if (appleSeconds === null || appleSeconds === void 0 || !Number.isFinite(appleSeconds)) return null;
346
+ const d = /* @__PURE__ */ new Date((appleSeconds + epochOffset) * 1e3);
347
+ if (Number.isNaN(d.getTime())) return null;
348
+ if (allDay) {
349
+ const zone = resolveZone(tz);
350
+ if (zone) {
351
+ const p = partsIn(d, zone);
352
+ return {
353
+ allDay: true,
354
+ day: `${p.year}-${p.month}-${p.day}`,
355
+ timeZone: null
356
+ };
357
+ }
358
+ return {
359
+ allDay: true,
360
+ day: `${d.getFullYear()}-${pad$1(d.getMonth() + 1)}-${pad$1(d.getDate())}`,
361
+ timeZone: null
362
+ };
363
+ }
364
+ const zone = resolveZone(tz);
365
+ if (!zone) return {
366
+ allDay: false,
367
+ iso: toLocalIso(d),
368
+ timeZone: null
369
+ };
370
+ const p = partsIn(d, zone);
371
+ const raw = p.timeZoneName ?? "";
372
+ const offset = raw === "GMT" ? "Z" : raw.replace(/^GMT/, "");
373
+ return {
374
+ allDay: false,
375
+ iso: `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${offset}`,
376
+ timeZone: zone
377
+ };
378
+ };
379
+ //#endregion
380
+ //#region src/client/jxa/core.ts
381
+ /**
382
+ * JXA script fragments.
383
+ *
384
+ * Every script here is a static constant. None may contain a template
385
+ * interpolation — `assertStaticScript` rejects any script containing a dollar
386
+ * sign followed by a brace, including template literals written INSIDE the JXA
387
+ * source. Use string concatenation in JXA code.
388
+ *
389
+ * Every script follows the same contract:
390
+ * - it reads its parameters from `JSON.parse(argv[0])`
391
+ * - it returns `JSON.stringify({ok: true, data})` on success
392
+ * - it returns `JSON.stringify({ok: false, error: {code, message}})` on an
393
+ * application-level failure, still exiting 0
394
+ * so a non-zero exit always means infrastructure rather than "no such event".
395
+ *
396
+ * ## There is no read.ts here, and that is the design
397
+ *
398
+ * Mail, Notes and Reminders all carry an Apple Events read lane. Calendar does
399
+ * not, because `docs/calendar.md` measured one: a single ±90-day range query
400
+ * costs 3.4 s over 1,349 events, and the cost falls per ROUND TRIP rather than
401
+ * per event — about 2 s per property, flat — so no batching rescues it. That is
402
+ * not a slower fallback, it is no fallback. Reads go through the file lane and
403
+ * these scripts exist only to make Calendar change something.
404
+ *
405
+ * `test/jxa.test.ts` asserts that `read.ts` does not exist, so this stays a
406
+ * decision rather than an accident.
407
+ *
408
+ * ## Finding an event costs a bulk fetch, which is why refs carry a calendar
409
+ *
410
+ * Calendar has no `events.byId()`. The two options are `whose({uid})`, measured
411
+ * at 4.5-7.3 s and — worse than slow — UNSTABLE across runs, or one bulk
412
+ * `cal.events.uid()` fetch plus an index in JS at about 1.8 s. The second only
413
+ * stays affordable if it is scoped to ONE calendar, so `ref.ts` carries the
414
+ * calendar uid and every script below narrows before it scans.
415
+ */
416
+ /**
417
+ * Shared prelude.
418
+ *
419
+ * THE BUNDLE IDENTIFIER IS `com.apple.iCal`. Calendar.app kept the id it
420
+ * shipped with as iCal, and this is the only surface in the project where the
421
+ * display name and the bundle id disagree — `Application("Calendar")` is
422
+ * correct, and `com.apple.Calendar` does not exist.
423
+ * `runningApplicationsWithBundleIdentifier` matches exactly and does not fold
424
+ * case, so getting this wrong makes every write report "not running".
425
+ */
426
+ const PRELUDE = `
427
+ ObjC.import("AppKit");
428
+
429
+ function isCalendarRunning() {
430
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.iCal");
431
+ return apps.count > 0;
432
+ }
433
+
434
+ function ok(data) { return JSON.stringify({ ok: true, data: data }); }
435
+ function err(code, message) { return JSON.stringify({ ok: false, error: { code: code, message: String(message) } }); }
436
+
437
+ function iso(d) {
438
+ try { return d ? d.toISOString() : null; } catch (e) { return null; }
439
+ }
440
+
441
+ /** Read one property defensively: Calendar throws on properties it cannot supply. */
442
+ function prop(fn, fallback) {
443
+ try {
444
+ var v = fn();
445
+ return v === undefined ? fallback : v;
446
+ } catch (e) {
447
+ return fallback;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Find a calendar BY NAME.
453
+ *
454
+ * MEASURED, macOS 26.6: \`calendar.uid()\` throws \`AppleEvent handler failed\`
455
+ * (-10000) for EVERY calendar, including ones this process just created. So a
456
+ * calendar cannot be addressed by uid across Apple Events at all, and the
457
+ * store's \`Calendar.UUID\` has no counterpart on this side.
458
+ *
459
+ * This is NOT the event id bridge, which docs/calendar.md measured as exact
460
+ * (\`CalendarItem.UUID\` matched an event's \`uid\` 198/198). That result was
461
+ * assumed to carry over to calendars; it does not. Events are addressable by
462
+ * uid, calendars are addressable by name, and the caller resolves the name
463
+ * before it gets here — including refusing an ambiguous one, which this side
464
+ * has no way to detect.
465
+ */
466
+ function findCalendar(C, wanted) {
467
+ if (!wanted) {
468
+ return prop(function () { return C.defaultCalendar(); }, null);
469
+ }
470
+ var cals = prop(function () { return C.calendars(); }, []);
471
+ for (var i = 0; i < cals.length; i++) {
472
+ var name = prop(function () { return String(cals[i].name()); }, null);
473
+ if (name === wanted) return cals[i];
474
+ }
475
+ return null;
476
+ }
477
+
478
+ /**
479
+ * Find one event inside a calendar, by uid.
480
+ *
481
+ * ONE bulk fetch of every uid in the calendar, then an index in JS.
482
+ *
483
+ * Deliberately NOT a specifier applied to the events collection, which was
484
+ * measured slower AND unstable — 4,564 / 5,290 / 7,303 ms across three runs of
485
+ * the same query — against a steady 3.4 s for a bulk scan. test/jxa.test.ts
486
+ * enforces that by string match, which is why the disallowed form is described
487
+ * here rather than written out.
488
+ */
489
+ function findEvent(cal, uid) {
490
+ var uids = prop(function () { return cal.events.uid(); }, null);
491
+ if (!uids) return null;
492
+ for (var i = 0; i < uids.length; i++) {
493
+ if (String(uids[i]) === uid) {
494
+ return prop(function () { return cal.events[i]; }, null);
495
+ }
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /** Millisecond-tolerant instant comparison, for the excluded-dates path. */
501
+ function sameInstant(a, b) {
502
+ if (!a || !b) return false;
503
+ return Math.abs(a.getTime() - b.getTime()) < 1000;
504
+ }
505
+
506
+ /**
507
+ * Whether a calendar accepts writes.
508
+ *
509
+ * Subscribed calendars — holidays, birthdays, anything added by URL — are
510
+ * read-only, and assigning to one fails deep inside Calendar with a message
511
+ * that does not say so. Asking first turns that into an error naming the cause.
512
+ */
513
+ function isWritable(cal) {
514
+ return prop(function () { return cal.writable(); }, true);
515
+ }
516
+
517
+ function readback(ev, cal) {
518
+ return {
519
+ uid: prop(function () { return String(ev.uid()); }, null),
520
+ summary: prop(function () { return String(ev.summary()); }, null),
521
+ startDate: iso(prop(function () { return ev.startDate(); }, null)),
522
+ endDate: iso(prop(function () { return ev.endDate(); }, null)),
523
+ alldayEvent: prop(function () { return ev.alldayEvent(); }, false),
524
+ location: prop(function () { var v = ev.location(); return v === null ? null : String(v); }, null),
525
+ description: prop(function () { var v = ev.description(); return v === null ? null : String(v); }, null),
526
+ url: prop(function () { var v = ev.url(); return v === null ? null : String(v); }, null),
527
+ stampDate: iso(prop(function () { return ev.stampDate(); }, null)),
528
+ // No calendarUid: cal.uid() throws on every calendar (see findCalendar).
529
+ // The caller already knows which calendar it targeted, and re-derives the
530
+ // store uuid from this name.
531
+ calendarName: prop(function () { return String(cal.name()); }, null)
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Apply the optional fields a create and an update have in common.
537
+ *
538
+ * ## The dates go first, and in an order that is never briefly invalid
539
+ *
540
+ * MEASURED: moving an event later failed with
541
+ *
542
+ * Failed to save event [...], with error
543
+ * [{ NSLocalizedDescription = "The start date must be before the end date." }]
544
+ *
545
+ * because start and end are two assignments, not one. Setting start to 16:00 on
546
+ * an event still ending at 15:30 makes the interval invalid, and EventKit
547
+ * validates on save rather than on assignment. Which order is safe depends on
548
+ * which way the event is moving, so the current end decides: moving later, the
549
+ * end is pushed out first; moving earlier, the start is pulled back first.
550
+ *
551
+ * They also go BEFORE the text fields. Apple Events has no transaction, so a
552
+ * failure part-way through leaves whatever already applied — and the first
553
+ * version of this wrote the new location, then failed on the dates, leaving the
554
+ * event half-updated. Doing the fragile part first means a date failure changes
555
+ * nothing else.
556
+ */
557
+ function applyFields(ev, f) {
558
+ var newStart = f.startDate !== undefined && f.startDate !== null ? new Date(f.startDate) : null;
559
+ var newEnd = f.endDate !== undefined && f.endDate !== null ? new Date(f.endDate) : null;
560
+
561
+ if (newStart !== null && newEnd !== null) {
562
+ var currentEnd = prop(function () { return ev.endDate(); }, null);
563
+ var movingLater = currentEnd === null || newEnd.getTime() >= currentEnd.getTime();
564
+ if (movingLater) {
565
+ ev.endDate = newEnd;
566
+ ev.startDate = newStart;
567
+ } else {
568
+ ev.startDate = newStart;
569
+ ev.endDate = newEnd;
570
+ }
571
+ } else if (newStart !== null) {
572
+ ev.startDate = newStart;
573
+ } else if (newEnd !== null) {
574
+ ev.endDate = newEnd;
575
+ }
576
+
577
+ if (f.allDay !== undefined && f.allDay !== null) ev.alldayEvent = Boolean(f.allDay);
578
+ if (f.summary !== undefined && f.summary !== null) ev.summary = String(f.summary);
579
+ if (f.location !== undefined) ev.location = f.location === null ? "" : String(f.location);
580
+ if (f.description !== undefined) ev.description = f.description === null ? "" : String(f.description);
581
+ if (f.url !== undefined) ev.url = f.url === null ? "" : String(f.url);
582
+ }
583
+ `;
584
+ /**
585
+ * Wrap a script body in the prelude and the liveness gate.
586
+ *
587
+ * Every script in `write.ts` sets `allowLaunch`, and that is not an oversight:
588
+ * a write is a deliberate side effect, so launching Calendar to perform one is
589
+ * expected in a way that launching it for a read never is. There are no read
590
+ * scripts here to keep it false for.
591
+ */
592
+ const script = (body, opts = {}) => `
593
+ ${PRELUDE}
594
+ function run(argv) {
595
+ var p = JSON.parse(argv[0] || "{}");
596
+ if (!isCalendarRunning() && !${opts.allowLaunch ? "true" : "false"}) {
597
+ return err("APP_NOT_RUNNING", "Calendar is not running.");
598
+ }
599
+ try {
600
+ var C = Application("Calendar");
601
+ ${body}
602
+ } catch (e) {
603
+ var msg = String(e && e.message ? e.message : e);
604
+ if (msg.indexOf("-1743") !== -1) return err("NOT_AUTHORIZED", msg);
605
+ return err("SCRIPT_ERROR", msg);
606
+ }
607
+ }
608
+ `;
609
+ //#endregion
610
+ //#region src/client/jxa/write.ts
611
+ /**
612
+ * Mutating scripts.
613
+ *
614
+ * These are the only place Calendar is asked to change anything, and every one
615
+ * re-reads the affected event afterwards: what a tool returns is what Calendar
616
+ * STORED, never what the caller requested. The two differ more often than you
617
+ * would expect — setting `alldayEvent` reshapes the start and end, and Calendar
618
+ * decides the final values, not us.
619
+ *
620
+ * Dates arrive as ISO-8601 strings carrying an explicit offset (see `dates.ts`)
621
+ * and are turned back into Date objects here. Passing a bare local string would
622
+ * have Calendar resolve it in whatever zone it feels like.
623
+ *
624
+ * ## Real side effects, said plainly
625
+ *
626
+ * A create on a CalDAV or Exchange calendar syncs within seconds and other
627
+ * people see it. There is no draft state and no undo. That is why the tools
628
+ * that call these carry the warning in their descriptions, and why attendees
629
+ * are not a parameter anywhere in this file: adding one emails a human.
630
+ */
631
+ const CREATE_EVENT = script(`
632
+ var cal = findCalendar(C, p.calendar);
633
+ if (!cal) return err("CALENDAR_NOT_FOUND", p.calendar || "(default)");
634
+ if (!isWritable(cal)) return err("CALENDAR_NOT_WRITABLE", prop(function () { return String(cal.name()); }, "?"));
635
+
636
+ // Build with the required dates, push, THEN apply the optional fields.
637
+ // An object that is not yet in a container has nowhere to store a property,
638
+ // so assignments made before the push are silently lost.
639
+ var ev = C.Event({
640
+ summary: String(p.summary),
641
+ startDate: new Date(p.startDate),
642
+ endDate: new Date(p.endDate)
643
+ });
644
+ cal.events.push(ev);
645
+ applyFields(ev, p);
646
+
647
+ return ok(readback(ev, cal));
648
+ `, { allowLaunch: true });
649
+ const UPDATE_EVENT = script(`
650
+ var cal = findCalendar(C, p.calendar);
651
+ if (!cal) return err("CALENDAR_NOT_FOUND", p.calendar || "(default)");
652
+ if (!isWritable(cal)) return err("CALENDAR_NOT_WRITABLE", prop(function () { return String(cal.name()); }, "?"));
653
+
654
+ var ev = findEvent(cal, String(p.uid));
655
+ if (!ev) return err("EVENT_NOT_FOUND", String(p.uid));
656
+
657
+ applyFields(ev, p);
658
+ return ok(readback(ev, cal));
659
+ `, { allowLaunch: true });
660
+ /**
661
+ * Delete whole events, and VERIFY that each one actually went.
662
+ *
663
+ * MEASURED, macOS 26.6: `C.delete(ev)` on a RECURRING event neither throws nor
664
+ * deletes. Event count before 1, after 1, still present — while the same call
665
+ * removes a non-repeating event correctly. Trusting "it did not throw" therefore
666
+ * reported `deleted: true` for an event that is still on the calendar, which is
667
+ * the worst thing a delete can say.
668
+ *
669
+ * So the uid list is re-read afterwards and `deleted` is decided by ABSENCE.
670
+ * The sibling script that excluded one occurrence had this check from the start
671
+ * and it is what caught that property being broken; this one did not, and this
672
+ * is what that omission cost.
673
+ *
674
+ * Per-id results rather than a bulk throw: deleting five events where the third
675
+ * has already gone should remove four and say which one was missing.
676
+ */
677
+ const DELETE_EVENTS = script(`
678
+ var cal = findCalendar(C, p.calendar);
679
+ if (!cal) return err("CALENDAR_NOT_FOUND", p.calendar || "(default)");
680
+ if (!isWritable(cal)) return err("CALENDAR_NOT_WRITABLE", prop(function () { return String(cal.name()); }, "?"));
681
+
682
+ var results = [];
683
+ for (var i = 0; i < p.uids.length; i++) {
684
+ var uid = String(p.uids[i]);
685
+ var ev = findEvent(cal, uid);
686
+ if (!ev) {
687
+ results.push({ uid: uid, found: false, deleted: false, repeats: false });
688
+ continue;
689
+ }
690
+ var repeats = false;
691
+ try {
692
+ var r = ev.recurrence();
693
+ repeats = r !== null && r !== undefined && String(r) !== "";
694
+ } catch (e) {
695
+ repeats = false;
696
+ }
697
+ var threw = null;
698
+ try { C.delete(ev); } catch (e) { threw = String(e).slice(0, 120); }
699
+ results.push({ uid: uid, found: true, deleted: null, repeats: repeats, error: threw });
700
+ }
701
+
702
+ // ONE bulk re-read decides the truth for every id at once.
703
+ var after = prop(function () { return cal.events.uid(); }, null);
704
+ for (var j = 0; j < results.length; j++) {
705
+ if (!results[j].found) continue;
706
+ if (after === null) {
707
+ results[j].deleted = null;
708
+ results[j].reason = "could not re-read the calendar to confirm";
709
+ continue;
710
+ }
711
+ var gone = true;
712
+ for (var k = 0; k < after.length; k++) {
713
+ if (String(after[k]) === results[j].uid) gone = false;
714
+ }
715
+ results[j].deleted = gone;
716
+ if (!gone) {
717
+ results[j].reason = results[j].repeats
718
+ ? "Calendar did not delete it and reported no error, which is what it does for a repeating event. Delete it in Calendar.app."
719
+ : "Calendar reported no error but the event is still there.";
720
+ }
721
+ }
722
+ return ok({ results: results });
723
+ `, { allowLaunch: true });
724
+ /**
725
+ * REMOVED: excluding one occurrence, which Calendar cannot do.
726
+ *
727
+ * MEASURED, macOS 26.6, against a real repeating event:
728
+ *
729
+ * ev.excludedDates() -> ["1903-12-31T23:50:39.000Z"]
730
+ * ev.excludedDates = [aDate] -> TypeError: undefined is not an object
731
+ *
732
+ * The read returns a sentinel rather than the empty list the event actually
733
+ * has, and the assignment throws — while `ev.summary = "x"` on the very same
734
+ * specifier works, so this is the property, not the specifier or the lane.
735
+ *
736
+ * A script was written here that assigned the whole array back and then read it
737
+ * again to confirm, precisely because a silent no-op on a delete is the worst
738
+ * lie this surface could tell. The verification worked: it caught this. But a
739
+ * write path that can only ever report failure is not a capability, so the tool
740
+ * no longer offers "delete one occurrence" at all — `delete_events` refuses an
741
+ * occurrence ref and says why, which is the same shape as `update_event`.
742
+ *
743
+ * Deleting a single occurrence is possible in Calendar.app itself, so this is a
744
+ * limit of the scripting interface rather than of the data.
745
+ */
746
+ //#endregion
747
+ //#region src/client/locate.ts
748
+ /**
749
+ * Find Calendar's store.
750
+ *
751
+ * ## Why this is the easy case
752
+ *
753
+ * Reminders keeps its database under a generated directory name, so resolving
754
+ * it means *listing* a protected directory — which is itself the privileged
755
+ * operation, leaving no path to even stat without the grant. Calendar does not:
756
+ *
757
+ * ~/Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb
758
+ *
759
+ * is a constant. `statSync` succeeds on a TCC-protected file (only `access(2)`
760
+ * is denied — see packages/core/src/fs.ts), so this locator can tell "exists but
761
+ * unreadable" from "not there at all" with no permission whatsoever. Those are
762
+ * different failures with different fixes, and saying so is most of what
763
+ * diagnostics is for.
764
+ *
765
+ * ## Why it still walks the container
766
+ *
767
+ * `docs/calendar.md` recorded per-account stores sitting beside the main one,
768
+ * and the probe picked between them by size. The known filename is *preferred*,
769
+ * so the common case costs one `describeStore` and no listing at all; the walk
770
+ * is a fallback for a machine whose layout differs, and for the day Apple moves
771
+ * the file the way it moved Reminders' out of `~/Library/Reminders`.
772
+ */
773
+ /** `group.com.apple.calendar`, under `~/Library/Group Containers`. */
774
+ const GROUP_CONTAINER = "group.com.apple.calendar";
775
+ /** The observed filename. Preferred when present; not required. */
776
+ const STORE_FILENAME = "Calendar.sqlitedb";
777
+ /** Sits beside the store. 32 KB, and its contents are not used by this server. */
778
+ const EXTRAS_FILENAME = "Extras.db";
779
+ /** Depth cap for the fallback walk. The real store sits at depth 0. */
780
+ const MAX_DEPTH = 3;
781
+ const STORE_SUFFIX = /\.(sqlitedb|sqlite)$/i;
782
+ const defaultContainerPath = (home = homedir()) => join(home, "Library", "Group Containers", GROUP_CONTAINER);
783
+ const defaultStorePath = (home = homedir()) => join(defaultContainerPath(home), STORE_FILENAME);
784
+ const listDir = (dir) => {
785
+ try {
786
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? [] : [e.name]);
787
+ } catch {
788
+ return [];
789
+ }
790
+ };
791
+ const walk = (dir, depth, out) => {
792
+ if (depth > MAX_DEPTH) return out;
793
+ let entries;
794
+ try {
795
+ entries = readdirSync(dir, { withFileTypes: true });
796
+ } catch {
797
+ return out;
798
+ }
799
+ for (const e of entries) {
800
+ const p = join(dir, e.name);
801
+ if (e.isDirectory()) walk(p, depth + 1, out);
802
+ else if (STORE_SUFFIX.test(e.name)) out.push(p);
803
+ }
804
+ return out;
805
+ };
806
+ const FDA_HINT = "Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Calendar.app does nothing — the reader needs the permission.";
807
+ const locateStore = (opts = {}) => {
808
+ const containerPath = defaultContainerPath(opts.home);
809
+ if (opts.storePath) {
810
+ const facts = describeStore(opts.storePath);
811
+ return {
812
+ ...facts,
813
+ containerPath,
814
+ storePath: opts.storePath,
815
+ candidates: [{
816
+ ...facts,
817
+ path: opts.storePath
818
+ }],
819
+ containerListable: true,
820
+ extrasPresent: false,
821
+ reason: facts.readable ? null : facts.exists ? `The store at ${opts.storePath} exists but cannot be read. ${FDA_HINT}` : `No file at ${opts.storePath}. APPLE_CALENDAR_STORE points at nothing.`
822
+ };
823
+ }
824
+ const known = defaultStorePath(opts.home);
825
+ const knownFacts = describeStore(known);
826
+ const listing = listDir(containerPath);
827
+ const containerListable = listing.length > 0;
828
+ const extrasPresent = listing.includes(EXTRAS_FILENAME);
829
+ const paths = knownFacts.readable ? [known] : walk(containerPath, 0, []);
830
+ const candidates = (paths.includes(known) ? paths : [known, ...paths]).map((p) => ({
831
+ ...describeStore(p),
832
+ path: p
833
+ })).filter((c) => c.exists).toSorted((a, b) => (b.size ?? 0) - (a.size ?? 0));
834
+ const chosen = knownFacts.readable ? {
835
+ ...knownFacts,
836
+ path: known
837
+ } : candidates.find((c) => c.readable) ?? candidates[0] ?? null;
838
+ const reason = chosen?.readable ? null : knownFacts.exists ? `Found the Calendar store at ${known} but cannot read it. ${FDA_HINT}` : containerListable ? `No store file under ${containerPath}. Has Calendar ever been set up on this account?` : `Neither ${known} nor its container could be reached. If Calendar is set up on this account this is a permission problem. ${FDA_HINT}`;
839
+ return {
840
+ ...chosen ? {
841
+ exists: chosen.exists,
842
+ readable: chosen.readable,
843
+ size: chosen.size,
844
+ mtime: chosen.mtime,
845
+ walPresent: chosen.walPresent,
846
+ walSizeBytes: chosen.walSizeBytes
847
+ } : knownFacts,
848
+ containerPath,
849
+ storePath: chosen?.path ?? null,
850
+ candidates,
851
+ containerListable,
852
+ extrasPresent,
853
+ reason
854
+ };
855
+ };
856
+ //#endregion
857
+ //#region src/client/recurrence.ts
858
+ /**
859
+ * Merging the two legs of a range query, and saying honestly what the result
860
+ * does not cover.
861
+ *
862
+ * ## Why this is its own module
863
+ *
864
+ * It is the piece most likely to be rewritten. `store.ts` knows how to ask the
865
+ * database questions; this file encodes what the answers MEAN, and that meaning
866
+ * rests on measurements (`docs/calendar.md`) rather than on documentation Apple
867
+ * publishes. Keeping it separate means a future correction swaps this file
868
+ * rather than picking through SQL.
869
+ *
870
+ * ## Why a merge is needed at all
871
+ *
872
+ * Measured: `OccurrenceCache` holds 489 distinct parents, of which **456 carry
873
+ * no recurrence rule**. So the cache is not "the repeating events" — it holds
874
+ * plain one-shot events too, and a naive union of "items" and "occurrences"
875
+ * double-counts most of an ordinary calendar. Dedupe here is load-bearing, not
876
+ * defensive.
877
+ *
878
+ * ## Why the coverage edge is published rather than hidden
879
+ *
880
+ * The cache reaches about two years either side of today on the probed store,
881
+ * which is a real expansion rather than a month-view cache. It is still an edge.
882
+ * A query running past it gets fewer repeating events than it should, and a
883
+ * short list of events is indistinguishable from a free afternoon — the quietest
884
+ * possible failure on this surface. So the edge travels with the result.
885
+ */
886
+ /**
887
+ * Identity for deduping.
888
+ *
889
+ * `(uuid, rendered start)` rather than uuid alone: two occurrences of the same
890
+ * series are the same event at different times, and collapsing them by uuid
891
+ * would return a weekly meeting once — the exact bug the two-leg design exists
892
+ * to avoid.
893
+ *
894
+ * ## Why the RENDERED start and not the raw column
895
+ *
896
+ * MEASURED, against a live calendar: an all-day event created through this
897
+ * server came back TWICE — once from each leg, same uuid, both rendering as
898
+ * 17 September. `CalendarItem.start_date` and `OccurrenceCache.occurrence_date`
899
+ * do not hold the identical number for an all-day event, so a key built on the
900
+ * raw value saw two different events and let both through.
901
+ *
902
+ * Rendering first collapses that: two all-day rows for one uuid on one DAY are
903
+ * the same event, whatever the two columns disagree about underneath. For a
904
+ * timed event the rendered ISO is second-precision, which also absorbs the
905
+ * float wobble a REAL column can introduce.
906
+ */
907
+ const keyOf = (r, epochOffset) => {
908
+ const at = renderInstant(r.startApple, r.startTz, r.allDay, epochOffset);
909
+ const when = at ? at.allDay ? at.day : at.iso : String(r.startApple);
910
+ return `${String(r.uuid).toUpperCase()}|${when}`;
911
+ };
912
+ /**
913
+ * The identity of the occurrence a detached row REPLACES.
914
+ *
915
+ * When someone drags one instance of a series to a new time, Calendar writes a
916
+ * detached `CalendarItem` carrying `orig_item_id` (the series) and `orig_date`
917
+ * (the slot it came from). The stale cache row for that slot may still exist, so
918
+ * keying on `(uuid, start)` alone will not catch it: the instants differ, which
919
+ * is the entire point of the move.
920
+ */
921
+ const replacedKeyOf = (r) => r.origItemPk !== null && r.origDateApple !== null ? `${r.origItemPk}|${r.origDateApple}` : null;
922
+ const occurrenceSlotOf = (r) => `${r.itemPk}|${r.startApple}`;
923
+ const mergeRange = (opts) => {
924
+ const seen = /* @__PURE__ */ new Set();
925
+ const out = [];
926
+ let dropped = 0;
927
+ const replaced = /* @__PURE__ */ new Set();
928
+ for (const r of opts.items) {
929
+ const k = replacedKeyOf(r);
930
+ if (k) replaced.add(k);
931
+ }
932
+ for (const r of [...opts.items, ...opts.occurrences]) {
933
+ if (r.startApple === null) {
934
+ dropped += 1;
935
+ continue;
936
+ }
937
+ if (r.source === "occurrence" && replaced.has(occurrenceSlotOf(r))) continue;
938
+ const k = keyOf(r, opts.epochOffset);
939
+ if (seen.has(k)) continue;
940
+ seen.add(k);
941
+ out.push(r);
942
+ }
943
+ out.sort((a, b) => (a.startApple ?? 0) - (b.startApple ?? 0));
944
+ const merged = {
945
+ rows: out.slice(0, opts.limit),
946
+ expansion: opts.hasOccurrenceCache ? "expanded" : "unavailable",
947
+ coverage: opts.coverage,
948
+ dropped
949
+ };
950
+ if (!opts.hasOccurrenceCache) {
951
+ merged.expansionReason = opts.unavailableReason ?? "this store has no OccurrenceCache table, so occurrences of repeating events are not expanded; each repeating event appears once, at its series start";
952
+ return merged;
953
+ }
954
+ const cov = opts.coverage;
955
+ if (cov) {
956
+ const past = opts.fromApple < cov.fromApple;
957
+ const future = opts.toApple > cov.toApple;
958
+ if (past || future) merged.truncated = {
959
+ reason: "the requested window extends past the range the store has expanded, so repeating events outside it are missing from this result",
960
+ ...past ? { uncoveredFromApple: opts.fromApple } : {},
961
+ ...future ? { uncoveredToApple: opts.toApple } : {},
962
+ affects: "repeating events only — single events are correct at any distance"
963
+ };
964
+ }
965
+ return merged;
966
+ };
967
+ //#endregion
968
+ //#region src/client/ref.ts
969
+ /**
970
+ * CalendarRef — the one identifier any tool accepts or returns.
971
+ *
972
+ * Wire format: c1:<calendarUid>/<occurrence>/<eventUid>
973
+ *
974
+ * calendarUid `Calendar.UUID`. Always a UUID in this store.
975
+ * occurrence "-" for a single event or a whole series, otherwise the
976
+ * occurrence start as ISO-8601 basic with offset,
977
+ * e.g. 20260821T090000+0200.
978
+ * eventUid `CalendarItem.UUID`, verbatim, as the greedy tail.
979
+ *
980
+ * The `c1:` prefix follows the same reasoning as Notes' `n1:` and Reminders'
981
+ * `r1:`: if the scheme ever changes, a versioned prefix makes that an additive
982
+ * change instead of a silent reinterpretation of every ref already sitting in a
983
+ * conversation.
984
+ *
985
+ * ## Why the uid is the greedy tail, and why `@` is not a separator
986
+ *
987
+ * `docs/calendar.md` measured the id bridge on an iCloud account, where every
988
+ * uid is a bare UUID. That is a property of the ACCOUNT, not of Calendar: a
989
+ * Google event's uid looks like `abc123def@google.com`, and an Exchange one is
990
+ * a long hex blob. Requiring a UUID here would work perfectly on the machine it
991
+ * was written on and fail completely on anyone else's — so the uid is carried
992
+ * through verbatim and the UUID is only extracted opportunistically.
993
+ *
994
+ * That also rules out `@` as a field separator, which is otherwise the obvious
995
+ * choice for pinning an occurrence to a time.
996
+ *
997
+ * ## Why the calendar uid rides along
998
+ *
999
+ * Calendar's scripting dictionary has no `events.byId()`. Finding an event over
1000
+ * Apple Events means either `whose({uid})` — measured at 4.5-7.3 s and, worse,
1001
+ * UNSTABLE across runs — or one bulk `cal.events.uid()` fetch and an index in
1002
+ * JS, at about 1.8 s. The bulk fetch is only affordable if it is scoped to ONE
1003
+ * calendar, so every write narrows by calendar before it scans.
1004
+ *
1005
+ * That is a concrete thing the file lane hands the write lane: the store knows
1006
+ * which calendar an event is in, so Apple Events never has to search for it.
1007
+ */
1008
+ const REF_VERSION = "c1";
1009
+ /** Used to FIND a uuid inside an id, never to require one. */
1010
+ const UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
1011
+ /** `20260821T090000+0200` / `20260821T070000Z` — ISO-8601 basic. */
1012
+ const BASIC = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z|[+-]\d{4})$/;
1013
+ const SEPARATOR = "/";
1014
+ const NO_OCCURRENCE = "-";
1015
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
1016
+ /** The inverse of BASIC: local wall clock plus its offset, no punctuation. */
1017
+ const toBasic = (d) => {
1018
+ const mins = -d.getTimezoneOffset();
1019
+ const sign = mins < 0 ? "-" : "+";
1020
+ const abs = Math.abs(mins);
1021
+ const zone = mins === 0 ? "Z" : `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
1022
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${zone}`;
1023
+ };
1024
+ const fromBasic = (text) => {
1025
+ const m = BASIC.exec(text);
1026
+ if (!m) return null;
1027
+ const [, y, mo, d, hh, mm, ss, zone] = m;
1028
+ const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}` + (zone === "Z" ? "Z" : `${zone.slice(0, 3)}:${zone.slice(3)}`);
1029
+ const when = new Date(iso);
1030
+ return Number.isNaN(when.getTime()) ? null : when;
1031
+ };
1032
+ const encodeRef = (calendarUid, eventUid, occurrenceStart) => [
1033
+ "c1:" + String(calendarUid ?? ""),
1034
+ occurrenceStart ? toBasic(occurrenceStart) : NO_OCCURRENCE,
1035
+ String(eventUid ?? "")
1036
+ ].join(SEPARATOR);
1037
+ const decodeRef = (raw) => {
1038
+ const text = String(raw ?? "");
1039
+ const colon = text.indexOf(":");
1040
+ const version = colon === -1 ? "" : text.slice(0, colon);
1041
+ if (version !== "c1") {
1042
+ if (/^[a-z]\d+$/.test(version)) throw new PreconditionError(`That is a "${version}:" ref, which belongs to another surface. This server issues "c1:" refs — get one from apple_calendar_list_events or apple_calendar_search_events.`);
1043
+ throw new PreconditionError(`Malformed calendar ref ${JSON.stringify(raw)}. Refs come from the search and list tools — construct them from those results rather than by hand.`, { expected: `c1:<calendarUid>/<occurrence>/<eventUid>` });
1044
+ }
1045
+ const body = text.slice(colon + 1);
1046
+ const first = body.indexOf(SEPARATOR);
1047
+ const second = body.indexOf(SEPARATOR, first + 1);
1048
+ if (first === -1 || second === -1) throw new PreconditionError(`Malformed calendar ref ${JSON.stringify(raw)}: expected two "${SEPARATOR}" separators.`, { expected: `c1:<calendarUid>/<occurrence>/<eventUid>` });
1049
+ const calendarUid = body.slice(0, first);
1050
+ const occurrence = body.slice(first + 1, second);
1051
+ const eventUid = body.slice(second + 1);
1052
+ if (!eventUid) throw new PreconditionError(`Malformed calendar ref ${JSON.stringify(raw)}: it names no event.`, { expected: `c1:<calendarUid>/<occurrence>/<eventUid>` });
1053
+ const occurrenceStart = occurrence === NO_OCCURRENCE ? null : fromBasic(occurrence);
1054
+ if (occurrence !== NO_OCCURRENCE && !occurrenceStart) throw new PreconditionError(`Malformed calendar ref ${JSON.stringify(raw)}: ${JSON.stringify(occurrence)} is not an occurrence start. Expected ISO-8601 basic, e.g. 20260821T090000+0200, or "-".`);
1055
+ return {
1056
+ calendarUid,
1057
+ eventUid,
1058
+ occurrenceStart,
1059
+ isOccurrence: occurrenceStart !== null
1060
+ };
1061
+ };
1062
+ /** The series a ref belongs to. Identity for a ref that is already a series. */
1063
+ const seriesRefOf = (ref) => encodeRef(ref.calendarUid, ref.eventUid);
1064
+ /** The bare UUID inside an id, when there is one. Null is legitimate, not an error. */
1065
+ const uuidOf = (id) => UUID.exec(String(id ?? ""))?.[1]?.toUpperCase() ?? null;
1066
+ //#endregion
1067
+ //#region src/client/store.ts
1068
+ /**
1069
+ * Calendar's file lane.
1070
+ *
1071
+ * ## Why there is a file lane at all
1072
+ *
1073
+ * Unlike Notes and Reminders, this one is justified on SPEED rather than
1074
+ * capability. `docs/calendar.md` measured a ±90-day range query over Apple
1075
+ * Events at 3.4 s across 1,349 events, with the cost falling per round trip
1076
+ * rather than per event (~1.8-2.2 s per property, flat), so no amount of
1077
+ * batching rescues it. Calendar's scripting dictionary is unusually complete —
1078
+ * attendees, alarms and recurrence are all in it — so the usual "the store buys
1079
+ * you the rich stuff" argument is much weaker here than elsewhere. The store is
1080
+ * how a range query returns before the caller gives up.
1081
+ *
1082
+ * ## What is measured, and what is not
1083
+ *
1084
+ * Measured (`docs/calendar.md`): the store is
1085
+ * `~/Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb`, it is
1086
+ * plain Core Data with no `Z_PRIMARYKEY`, the main table is `CalendarItem`, and
1087
+ * the id bridge to Apple Events is exact — `CalendarItem.UUID` against the `uid`
1088
+ * Calendar hands back, 198/198 sampled.
1089
+ *
1090
+ * NOT yet measured, and the reason this file introspects but does not query:
1091
+ * `OccurrenceCache` (1,946 rows) and `OccurrenceCacheDays` (2,630) both out-row
1092
+ * `CalendarItem` (1,350), which means expanded recurrences live outside the main
1093
+ * table and a naive `SELECT ... FROM CalendarItem` returns a weekly standup
1094
+ * once. `scripts/probe-calendar.mjs` was extended to settle that with a set diff
1095
+ * against Apple Events; until it has run on a machine with Full Disk Access,
1096
+ * shipping a range query would be guessing at the one thing that fails silently.
1097
+ * A short list is indistinguishable from a free afternoon.
1098
+ */
1099
+ /** Tables the lane cannot work without. Anything else degrades to a null field. */
1100
+ const REQUIRED = ["CalendarItem", "Calendar"];
1101
+ /**
1102
+ * The schema this was written against. Named in the drift error, not enforced.
1103
+ *
1104
+ * CAUTION: this is the PROBE's fingerprint and it is NOT comparable to the one
1105
+ * `diagnostics` reports. `fingerprintSchema` in packages/core orders
1106
+ * sqlite_master by `type, name`; `dumpSchema` in scripts/lib/probe-kit.mjs
1107
+ * orders it by a CASE expression putting tables before indexes, so the same
1108
+ * schema hashes to two different values. Measured here: this store reports
1109
+ * cd2424fea732 at runtime against the 2bf4e34ff75f docs/calendar.md recorded
1110
+ * from the probe, and Reminders shows the same split (510062aad004 at runtime
1111
+ * against the 278b001e3c55 in its own drift message and in docs/verify.md).
1112
+ *
1113
+ * So compare a probe fingerprint with a probe fingerprint. Reconciling the two
1114
+ * orderings is a one-line change in packages/core, but it moves the value for
1115
+ * every surface at once and is therefore its own decision.
1116
+ */
1117
+ const PROBED_FINGERPRINT = "2bf4e34ff75f";
1118
+ const PROBED_MACOS = "26.6";
1119
+ const bool = (v) => v === 1 || v === true;
1120
+ const num = (v) => typeof v === "number" ? v : null;
1121
+ const text = (v) => typeof v === "string" ? v : null;
1122
+ var CalendarStore = class {
1123
+ db;
1124
+ mode;
1125
+ caps;
1126
+ constructor(db, mode, caps) {
1127
+ this.db = db;
1128
+ this.mode = mode;
1129
+ this.caps = caps;
1130
+ }
1131
+ /**
1132
+ * Project a column, or a typed NULL when this store does not have it.
1133
+ *
1134
+ * The same guard `packages/reminders/src/client/store.ts` uses, and for the
1135
+ * same reason: the schema is reverse-engineered and unversioned, so an Apple
1136
+ * rename should cost one field rather than the whole lane.
1137
+ */
1138
+ #col(present, table, name, alias = name) {
1139
+ return present.has(name) ? `${table}."${name}" AS ${alias}` : `NULL AS ${alias}`;
1140
+ }
1141
+ #itemColumns() {
1142
+ const c = this.caps.itemColumns;
1143
+ return [
1144
+ `ci."ROWID" AS itemPk`,
1145
+ this.#col(c, "ci", "UUID", "uuid"),
1146
+ this.#col(c, "ci", "summary", "summary"),
1147
+ this.#col(c, "ci", "description", "description"),
1148
+ this.#col(c, "ci", "url", "url"),
1149
+ this.#col(c, "ci", "conference_url", "conferenceUrl"),
1150
+ this.#col(c, "ci", "all_day", "allDay"),
1151
+ this.#col(c, "ci", "start_tz", "startTz"),
1152
+ this.#col(c, "ci", "end_tz", "endTz"),
1153
+ this.#col(c, "ci", "status", "status"),
1154
+ this.#col(c, "ci", "invitation_status", "invitationStatus"),
1155
+ this.#col(c, "ci", "availability", "availability"),
1156
+ this.#col(c, "ci", "has_recurrences", "hasRecurrences"),
1157
+ this.#col(c, "ci", "has_attendees", "hasAttendees"),
1158
+ this.#col(c, "ci", "orig_item_id", "origItemPk"),
1159
+ this.#col(c, "ci", "orig_date", "origDateApple"),
1160
+ this.#col(c, "ci", "calendar_id", "calendarPk"),
1161
+ `cal."UUID" AS calendarUuid`,
1162
+ `cal."title" AS calendarTitle`,
1163
+ this.caps.hasLocation ? `loc."title" AS locationTitle` : `NULL AS locationTitle`
1164
+ ].join(",\n ");
1165
+ }
1166
+ #joins() {
1167
+ return `JOIN "Calendar" cal ON cal."ROWID" = ci."calendar_id"${this.caps.hasLocation && this.caps.itemColumns.has("location_id") ? `\n LEFT JOIN "Location" loc ON loc."ROWID" = ci."location_id"` : ""}`;
1168
+ }
1169
+ /**
1170
+ * Rows this lane must never return, whichever leg found them.
1171
+ *
1172
+ * `entity_type` is 2 for events. The probed store holds nothing else — 0 rows
1173
+ * carry a due date or a completion date — but `CalendarItem` shares its schema
1174
+ * with Reminders, so the predicate is cheap insurance rather than dead code.
1175
+ *
1176
+ * `hidden` and `phantom_master` are INFERRED rather than measured. A phantom
1177
+ * master is the placeholder row EventKit keeps for a series whose occurrences
1178
+ * have all been detached; showing it would put an event on the calendar that
1179
+ * Calendar.app does not draw. Both are excluded conservatively, and both are
1180
+ * on the list for the next probe run to confirm.
1181
+ */
1182
+ #excluded(alias = "ci") {
1183
+ const c = this.caps.itemColumns;
1184
+ const out = [];
1185
+ if (c.has("entity_type")) out.push(`${alias}."entity_type" = 2`);
1186
+ if (c.has("hidden")) out.push(`(${alias}."hidden" IS NULL OR ${alias}."hidden" = 0)`);
1187
+ if (c.has("phantom_master")) out.push(`(${alias}."phantom_master" IS NULL OR ${alias}."phantom_master" = 0)`);
1188
+ return out.length ? `AND ${out.join("\n AND ")}` : "";
1189
+ }
1190
+ #calendarFilter(uuids) {
1191
+ if (!uuids?.length) return {
1192
+ sql: "",
1193
+ params: []
1194
+ };
1195
+ return {
1196
+ sql: `AND cal."UUID" IN (${uuids.map(() => "?").join(", ")})`,
1197
+ params: [...uuids]
1198
+ };
1199
+ }
1200
+ #rowsFrom(sql, params, source) {
1201
+ return this.db.prepare(sql).all(...params).map((r) => ({
1202
+ itemPk: Number(r.itemPk),
1203
+ uuid: text(r.uuid),
1204
+ calendarPk: num(r.calendarPk),
1205
+ calendarUuid: text(r.calendarUuid),
1206
+ calendarTitle: text(r.calendarTitle),
1207
+ summary: text(r.summary),
1208
+ description: text(r.description),
1209
+ url: text(r.url),
1210
+ conferenceUrl: text(r.conferenceUrl),
1211
+ locationTitle: text(r.locationTitle),
1212
+ startApple: num(r.startApple),
1213
+ endApple: num(r.endApple),
1214
+ allDay: bool(r.allDay),
1215
+ startTz: text(r.startTz),
1216
+ endTz: text(r.endTz),
1217
+ status: num(r.status),
1218
+ invitationStatus: num(r.invitationStatus),
1219
+ availability: num(r.availability),
1220
+ hasRecurrences: bool(r.hasRecurrences),
1221
+ hasAttendees: bool(r.hasAttendees),
1222
+ origItemPk: num(r.origItemPk),
1223
+ origDateApple: num(r.origDateApple),
1224
+ source
1225
+ }));
1226
+ }
1227
+ /**
1228
+ * LEG 1 — items carried by the table itself.
1229
+ *
1230
+ * Overlap, not containment: `start < to AND COALESCE(end, start) > from`. A
1231
+ * naive `BETWEEN` on the start silently drops the all-hands that began at
1232
+ * 09:00 when the caller asked about 10:00 onward.
1233
+ *
1234
+ * Items whose occurrences are expanded in the cache are excluded here and
1235
+ * picked up by leg 2, so a repeating event is not also returned once at its
1236
+ * master start.
1237
+ */
1238
+ rangeItems(q) {
1239
+ const c = this.caps.itemColumns;
1240
+ if (!c.has("start_date")) return [];
1241
+ const cal = this.#calendarFilter(q.calendarUuids);
1242
+ const endExpr = c.has("end_date") ? `COALESCE(ci."end_date", ci."start_date")` : `ci."start_date"`;
1243
+ const notExpanded = c.has("has_recurrences") ? `AND (ci."has_recurrences" IS NULL OR ci."has_recurrences" = 0)` : this.caps.hasRecurrence ? `AND NOT EXISTS (SELECT 1 FROM "Recurrence" r WHERE r."owner_id" = ci."ROWID")` : "";
1244
+ const sql = `
1245
+ SELECT ci."start_date" AS startApple,
1246
+ ${c.has("end_date") ? `ci."end_date"` : `NULL`} AS endApple,
1247
+ ${this.#itemColumns()}
1248
+ FROM "CalendarItem" ci
1249
+ ${this.#joins()}
1250
+ WHERE ci."start_date" < ?
1251
+ AND ${endExpr} > ?
1252
+ ${notExpanded}
1253
+ ${this.#excluded()}
1254
+ ${cal.sql}
1255
+ ORDER BY ci."start_date" ASC
1256
+ LIMIT ?`;
1257
+ return this.#rowsFrom(sql, [
1258
+ q.toApple,
1259
+ q.fromApple,
1260
+ ...cal.params,
1261
+ q.limit
1262
+ ], "item");
1263
+ }
1264
+ /**
1265
+ * LEG 2 — expanded occurrences.
1266
+ *
1267
+ * `OccurrenceCache.event_id` -> `CalendarItem.ROWID` was measured at a 100%
1268
+ * resolve rate. `occurrence_date` is the start; `occurrence_start_date` exists
1269
+ * too and reaches only +256 days against `occurrence_date`'s +724, so using it
1270
+ * would silently truncate the far half of the window.
1271
+ */
1272
+ rangeOccurrences(q) {
1273
+ if (!this.caps.hasOccurrenceCache) return [];
1274
+ const o = this.caps.occurrenceColumns;
1275
+ if (!o.has("occurrence_date") || !o.has("event_id")) return [];
1276
+ const cal = this.#calendarFilter(q.calendarUuids);
1277
+ const endExpr = o.has("occurrence_end_date") ? `COALESCE(oc."occurrence_end_date", oc."occurrence_date")` : `oc."occurrence_date"`;
1278
+ const sql = `
1279
+ SELECT oc."occurrence_date" AS startApple,
1280
+ ${o.has("occurrence_end_date") ? `oc."occurrence_end_date"` : `NULL`} AS endApple,
1281
+ ${this.#itemColumns()}
1282
+ FROM "OccurrenceCache" oc
1283
+ JOIN "CalendarItem" ci ON ci."ROWID" = oc."event_id"
1284
+ ${this.#joins()}
1285
+ WHERE oc."occurrence_date" < ?
1286
+ AND ${endExpr} > ?
1287
+ ${this.#excluded()}
1288
+ ${cal.sql}
1289
+ ORDER BY oc."occurrence_date" ASC
1290
+ LIMIT ?`;
1291
+ return this.#rowsFrom(sql, [
1292
+ q.toApple,
1293
+ q.fromApple,
1294
+ ...cal.params,
1295
+ q.limit
1296
+ ], "occurrence");
1297
+ }
1298
+ /**
1299
+ * Text search, unbounded in time by default.
1300
+ *
1301
+ * Searching is the one place a caller legitimately wants all of history, so
1302
+ * the window is the caller's to set rather than a default. Runs over items
1303
+ * only: an occurrence carries no text of its own, and matching the series once
1304
+ * is what a search result should be.
1305
+ */
1306
+ searchItems(q) {
1307
+ const c = this.caps.itemColumns;
1308
+ const cal = this.#calendarFilter(q.calendarUuids);
1309
+ const needle = `%${escapeLike(q.text)}%`;
1310
+ const fields = ["summary"];
1311
+ if (q.scope === "full") {
1312
+ if (c.has("description")) fields.push("description");
1313
+ if (this.caps.hasLocation) fields.push("__location");
1314
+ }
1315
+ const match = fields.map((f) => f === "__location" ? `loc."title" LIKE ? ESCAPE '\\'` : `ci."${f}" LIKE ? ESCAPE '\\'`).join(" OR ");
1316
+ const sql = `
1317
+ SELECT ci."start_date" AS startApple,
1318
+ ${c.has("end_date") ? `ci."end_date"` : `NULL`} AS endApple,
1319
+ ${this.#itemColumns()}
1320
+ FROM "CalendarItem" ci
1321
+ ${this.#joins()}
1322
+ WHERE (${match})
1323
+ AND ci."start_date" < ?
1324
+ AND COALESCE(ci."end_date", ci."start_date") > ?
1325
+ ${this.#excluded()}
1326
+ ${cal.sql}
1327
+ ORDER BY ci."start_date" DESC
1328
+ LIMIT ?`;
1329
+ const params = [
1330
+ ...fields.map(() => needle),
1331
+ q.toApple,
1332
+ q.fromApple,
1333
+ ...cal.params,
1334
+ q.limit
1335
+ ];
1336
+ return this.#rowsFrom(sql, params, "item");
1337
+ }
1338
+ /** One event by its Apple Events uid. The bridge measured 198/198 exact. */
1339
+ byUuid(uuid) {
1340
+ const c = this.caps.itemColumns;
1341
+ if (!c.has("UUID")) return null;
1342
+ const sql = `
1343
+ SELECT ci."start_date" AS startApple,
1344
+ ${c.has("end_date") ? `ci."end_date"` : `NULL`} AS endApple,
1345
+ ${this.#itemColumns()}
1346
+ FROM "CalendarItem" ci
1347
+ ${this.#joins()}
1348
+ WHERE UPPER(ci."UUID") = ?
1349
+ ${this.#excluded()}
1350
+ LIMIT 1`;
1351
+ return this.#rowsFrom(sql, [uuid.toUpperCase()], "item")[0] ?? null;
1352
+ }
1353
+ /**
1354
+ * How far the expansion reaches.
1355
+ *
1356
+ * Published with every range result. Measured at -732 to +724 days on the
1357
+ * probed store, which is a real expansion rather than a month-view cache —
1358
+ * but it is still an edge, and nothing guarantees the next machine's is as
1359
+ * deep. A range running past it must say so rather than return a short list.
1360
+ */
1361
+ coverage() {
1362
+ if (!this.caps.hasOccurrenceCache || !this.caps.occurrenceColumns.has("occurrence_date")) return null;
1363
+ const row = this.db.prepare(`SELECT MIN("occurrence_date") AS lo, MAX("occurrence_date") AS hi, COUNT(*) AS n
1364
+ FROM "OccurrenceCache" WHERE "occurrence_date" IS NOT NULL`).get();
1365
+ const lo = num(row?.lo);
1366
+ const hi = num(row?.hi);
1367
+ if (lo === null || hi === null) return null;
1368
+ return {
1369
+ fromApple: lo,
1370
+ toApple: hi,
1371
+ rows: Number(row?.n ?? 0)
1372
+ };
1373
+ }
1374
+ calendars() {
1375
+ const c = this.caps.calendarColumns;
1376
+ const store = this.caps.storeColumns.size > 0;
1377
+ const sql = `
1378
+ SELECT ${this.#col(c, "cal", "UUID", "uuid")},
1379
+ ${this.#col(c, "cal", "title", "title")},
1380
+ ${this.#col(c, "cal", "color", "color")},
1381
+ ${this.#col(c, "cal", "type", "type")},
1382
+ ${this.#col(c, "cal", "sharing_status", "sharingStatus")},
1383
+ ${this.#col(c, "cal", "is_published", "isPublished")},
1384
+ ${this.#col(c, "cal", "subcal_url", "subcalUrl")},
1385
+ ${store ? `st."name"` : `NULL`} AS accountName
1386
+ FROM "Calendar" cal
1387
+ ${store ? `LEFT JOIN "Store" st ON st."ROWID" = cal."store_id"` : ""}
1388
+ ORDER BY ${c.has("display_order") ? `cal."display_order" ASC,` : ""} cal."title" ASC`;
1389
+ return this.db.prepare(sql).all().map((r) => ({
1390
+ uuid: text(r.uuid),
1391
+ title: text(r.title),
1392
+ color: text(r.color),
1393
+ type: text(r.type),
1394
+ accountName: text(r.accountName),
1395
+ isSubscribed: Boolean(text(r.subcalUrl)),
1396
+ isPublished: bool(r.isPublished),
1397
+ isShared: (num(r.sharingStatus) ?? 0) > 0,
1398
+ sharingStatus: num(r.sharingStatus)
1399
+ }));
1400
+ }
1401
+ accounts() {
1402
+ if (!this.caps.storeColumns.size) return [];
1403
+ return this.db.prepare(`SELECT st."name" AS name, st."type" AS type, COUNT(cal."ROWID") AS calendars
1404
+ FROM "Store" st
1405
+ LEFT JOIN "Calendar" cal ON cal."store_id" = st."ROWID"
1406
+ GROUP BY st."ROWID"
1407
+ ORDER BY st."name" ASC`).all().map((r) => ({
1408
+ name: text(r.name),
1409
+ type: num(r.type),
1410
+ calendars: Number(r.calendars ?? 0)
1411
+ }));
1412
+ }
1413
+ close() {
1414
+ try {
1415
+ this.db.close();
1416
+ } catch {}
1417
+ }
1418
+ };
1419
+ const introspect = (db) => {
1420
+ const itemColumns = new Set(columnsOf(db, "CalendarItem"));
1421
+ const calendarColumns = new Set(columnsOf(db, "Calendar"));
1422
+ for (const t of REQUIRED) if ((t === "CalendarItem" ? itemColumns : calendarColumns).size === 0) throw new SchemaDriftError(`This Calendar store has no ${t} table. It was probed on macOS ${PROBED_MACOS} with schema fingerprint ${PROBED_FINGERPRINT} (a PROBE fingerprint — compare it against another probe run, not against the one diagnostics reports); re-run \`pnpm probe:calendar\` to see what changed.`);
1423
+ const occurrenceColumns = new Set(columnsOf(db, "OccurrenceCache"));
1424
+ return {
1425
+ fingerprint: fingerprintSchema(db),
1426
+ itemColumns,
1427
+ calendarColumns,
1428
+ occurrenceColumns,
1429
+ recurrenceColumns: new Set(columnsOf(db, "Recurrence")),
1430
+ storeColumns: new Set(columnsOf(db, "Store")),
1431
+ hasOccurrenceCache: occurrenceColumns.size > 0,
1432
+ hasOccurrenceDays: columnsOf(db, "OccurrenceCacheDays").length > 0,
1433
+ hasRecurrence: columnsOf(db, "Recurrence").length > 0,
1434
+ hasExceptionDates: columnsOf(db, "ExceptionDate").length > 0,
1435
+ hasLocation: columnsOf(db, "Location").length > 0,
1436
+ hasAttachments: columnsOf(db, "Attachment").length > 0,
1437
+ hasParticipants: columnsOf(db, "Participant").length > 0,
1438
+ hasAlarms: columnsOf(db, "Alarm").length > 0,
1439
+ epochOffset: CORE_DATA_EPOCH_OFFSET
1440
+ };
1441
+ };
1442
+ const openStore = (path, mode, logger) => {
1443
+ if (!path) return null;
1444
+ const { db, mode: used, validated } = openReadOnly(path, mode, {
1445
+ label: "Calendar store",
1446
+ envVar: "APPLE_CALENDAR_INDEX_MODE",
1447
+ validate: introspect,
1448
+ fatal: (err) => err instanceof SchemaDriftError,
1449
+ onFallback: () => logger?.debug?.("opened the Calendar store with immutable=1, which skips the write-ahead log — very recent changes may be missing until Calendar checkpoints.")
1450
+ });
1451
+ return new CalendarStore(db, used, validated);
1452
+ };
1453
+ //#endregion
1454
+ //#region src/client/calendar.ts
1455
+ /**
1456
+ * EventKit's public status constants, which this store appears to mirror.
1457
+ *
1458
+ * Taken from the framework rather than measured: `EKEventStatus` and
1459
+ * `EKParticipantStatus` are documented API, and a store written by EventKit is
1460
+ * overwhelmingly likely to hold the same integers. "Overwhelmingly likely" is
1461
+ * not "measured", so both filters are off unless the caller asks, every result
1462
+ * reports its own raw `status`, and `diagnostics` says the mapping is inferred.
1463
+ * Confirming it is on the probe's list.
1464
+ */
1465
+ const STATUS_CANCELLED = 3;
1466
+ const PARTICIPANT_DECLINED = 3;
1467
+ var AppleCalendarClient = class {
1468
+ config;
1469
+ runner;
1470
+ #logger;
1471
+ #now;
1472
+ #located = null;
1473
+ #store = null;
1474
+ #storeTried = false;
1475
+ constructor(opts) {
1476
+ this.config = opts.config;
1477
+ this.#logger = opts.logger;
1478
+ this.#now = opts.now ?? (() => /* @__PURE__ */ new Date());
1479
+ this.runner = opts.osascript ?? createOsascriptRunner({
1480
+ osascriptPath: opts.config.osascriptPath,
1481
+ timeoutMs: opts.config.osascriptTimeoutMs,
1482
+ surface: CALENDAR_SURFACE,
1483
+ logger: opts.logger
1484
+ });
1485
+ }
1486
+ /** Cached: the answer cannot change without the process being restarted anyway. */
1487
+ locate() {
1488
+ this.#located ??= locateStore({ storePath: this.config.storePath });
1489
+ return this.#located;
1490
+ }
1491
+ /**
1492
+ * The store, opened lazily and at most once.
1493
+ *
1494
+ * Returns null rather than throwing, because "no index" is a state the caller
1495
+ * has to render, not an exception. The reason lives on the locate result.
1496
+ */
1497
+ index() {
1498
+ if (this.#storeTried) return this.#store;
1499
+ this.#storeTried = true;
1500
+ if (this.config.indexMode === "off") return null;
1501
+ try {
1502
+ this.#store = openStore(this.locate().storePath, this.config.indexMode, this.#logger);
1503
+ } catch (err) {
1504
+ this.#logger?.debug?.("could not open the Calendar store", err);
1505
+ this.#store = null;
1506
+ }
1507
+ return this.#store;
1508
+ }
1509
+ /**
1510
+ * Drop the open handle so the next read reopens.
1511
+ *
1512
+ * Called after a write: Calendar owns the store and reconciles it against a
1513
+ * server, so an event created over Apple Events lands in the file on the
1514
+ * app's schedule, not ours.
1515
+ */
1516
+ invalidate() {
1517
+ this.#store?.close();
1518
+ this.#store = null;
1519
+ this.#storeTried = false;
1520
+ this.#located = null;
1521
+ }
1522
+ /**
1523
+ * The store, or a structured refusal.
1524
+ *
1525
+ * Never an empty list: Calendar has no Apple Events read lane to fall back to
1526
+ * (`docs/distribution.md`), so "no index" means this server cannot answer at
1527
+ * all — and an empty array would read as an empty calendar, which is the one
1528
+ * answer that must never be invented.
1529
+ */
1530
+ #require() {
1531
+ const store = this.index();
1532
+ if (store) return store;
1533
+ const located = this.locate();
1534
+ throw new IndexUnavailableError(located.reason ?? "The Calendar store could not be opened, and this surface has no Apple Events read lane to fall back to. Run apple_calendar_diagnostics for the details.");
1535
+ }
1536
+ #toApple(d, store) {
1537
+ return Math.round(d.getTime() / 1e3) - store.caps.epochOffset;
1538
+ }
1539
+ #fromApple(seconds, store) {
1540
+ return (/* @__PURE__ */ new Date((seconds + store.caps.epochOffset) * 1e3)).toISOString();
1541
+ }
1542
+ /** Resolve a caller's calendar name or uid to the uids the SQL filters on. */
1543
+ #calendarUuids(store, named) {
1544
+ const configured = this.config.calendars;
1545
+ const all = store.calendars();
1546
+ const pick = (want) => {
1547
+ const lower = want.toLowerCase();
1548
+ const hits = all.filter((c) => c.uuid?.toLowerCase() === lower || c.title?.toLowerCase() === lower);
1549
+ if (!hits.length) throw new CalendarNotFoundError(want, all.map((c) => c.title ?? "").filter(Boolean));
1550
+ return hits.map((c) => c.uuid).filter((u) => Boolean(u));
1551
+ };
1552
+ if (named) {
1553
+ const chosen = pick(named);
1554
+ if (configured.length) {
1555
+ const allowed = new Set(configured.flatMap(pick));
1556
+ const kept = chosen.filter((u) => allowed.has(u));
1557
+ if (!kept.length) throw new CalendarNotFoundError(named, configured);
1558
+ return kept;
1559
+ }
1560
+ return chosen;
1561
+ }
1562
+ if (configured.length) return configured.flatMap(pick);
1563
+ }
1564
+ #visible(row, opts) {
1565
+ if (!opts.cancelled && row.status === STATUS_CANCELLED) return false;
1566
+ if (!opts.declined && row.invitationStatus === PARTICIPANT_DECLINED) return false;
1567
+ return true;
1568
+ }
1569
+ #summarise(row, store) {
1570
+ const start = renderInstant(row.startApple, row.startTz, row.allDay, store.caps.epochOffset);
1571
+ /**
1572
+ * OPEN: the all-day `end` convention is not settled, so it is reported RAW.
1573
+ *
1574
+ * Measured against a live calendar, creating one all-day event on
1575
+ * 21 September: Apple Events reads the end back as `2026-09-21T21:59:59`
1576
+ * (23:59:59 local — inclusive), while the store renders a day later. The two
1577
+ * legs also disagree with each other: an occurrence row reports end on the
1578
+ * same day as start, an item row a day after.
1579
+ *
1580
+ * A "subtract a second before rendering" normalisation was tried and turned
1581
+ * out to be a no-op on real data, which means the anchoring differs in a way
1582
+ * that has not been pinned down — `start_date` reads as local midnight while
1583
+ * `end_date` appears to be anchored differently. Guessing again on top of an
1584
+ * unverified premise is how the start-of-day bug got here in the first
1585
+ * place, so this stays raw and documented until the raw columns are read.
1586
+ * See docs/calendar.md, "Still open".
1587
+ */
1588
+ const end = renderInstant(row.endApple, row.endTz ?? row.startTz, row.allDay, store.caps.epochOffset);
1589
+ const isOccurrence = row.source === "occurrence";
1590
+ return {
1591
+ ref: encodeRef(row.calendarUuid ?? "", row.uuid ?? "", isOccurrence && row.startApple !== null ? /* @__PURE__ */ new Date((row.startApple + store.caps.epochOffset) * 1e3) : null),
1592
+ summary: row.summary,
1593
+ start,
1594
+ end,
1595
+ allDay: row.allDay,
1596
+ calendar: row.calendarTitle,
1597
+ location: row.locationTitle,
1598
+ isOccurrence,
1599
+ ...isOccurrence ? { seriesRef: encodeRef(row.calendarUuid ?? "", row.uuid ?? "") } : {},
1600
+ status: row.status,
1601
+ invitationStatus: row.invitationStatus,
1602
+ source: row.source
1603
+ };
1604
+ }
1605
+ listEvents(filters) {
1606
+ const store = this.#require();
1607
+ const range = parseRange({
1608
+ from: filters.from,
1609
+ to: filters.to,
1610
+ defaultRangeDays: this.config.defaultRangeDays,
1611
+ maxRangeDays: this.config.maxRangeDays
1612
+ }, this.#now());
1613
+ const fromApple = this.#toApple(range.from, store);
1614
+ const toApple = this.#toApple(range.to, store);
1615
+ const calendarUuids = this.#calendarUuids(store, filters.calendar);
1616
+ const legLimit = Math.min(filters.limit * 4 + 50, 5e3);
1617
+ const q = {
1618
+ fromApple,
1619
+ toApple,
1620
+ limit: legLimit,
1621
+ ...calendarUuids ? { calendarUuids } : {}
1622
+ };
1623
+ const merged = mergeRange({
1624
+ items: store.rangeItems(q),
1625
+ occurrences: store.rangeOccurrences(q),
1626
+ coverage: store.coverage(),
1627
+ hasOccurrenceCache: store.caps.hasOccurrenceCache,
1628
+ fromApple,
1629
+ toApple,
1630
+ limit: legLimit,
1631
+ epochOffset: store.caps.epochOffset
1632
+ });
1633
+ const declined = filters.includeDeclined ?? this.config.includeDeclined;
1634
+ const cancelled = filters.includeCancelled ?? this.config.includeCancelled;
1635
+ return {
1636
+ events: merged.rows.filter((r) => this.#visible(r, {
1637
+ declined,
1638
+ cancelled
1639
+ })).slice(0, filters.limit).map((r) => this.#summarise(r, store)),
1640
+ expansion: merged.expansion,
1641
+ ...merged.expansionReason ? { expansionReason: merged.expansionReason } : {},
1642
+ window: {
1643
+ from: range.from.toISOString(),
1644
+ to: range.to.toISOString(),
1645
+ clamped: range.clamped
1646
+ },
1647
+ coverage: merged.coverage ? {
1648
+ from: this.#fromApple(merged.coverage.fromApple, store),
1649
+ to: this.#fromApple(merged.coverage.toApple, store),
1650
+ rows: merged.coverage.rows
1651
+ } : null,
1652
+ ...merged.truncated ? { truncated: {
1653
+ reason: merged.truncated.reason,
1654
+ affects: merged.truncated.affects,
1655
+ ...merged.truncated.uncoveredFromApple !== void 0 ? { uncoveredFrom: this.#fromApple(merged.truncated.uncoveredFromApple, store) } : {},
1656
+ ...merged.truncated.uncoveredToApple !== void 0 ? { uncoveredTo: this.#fromApple(merged.truncated.uncoveredToApple, store) } : {}
1657
+ } } : {},
1658
+ ...merged.dropped ? { dropped: merged.dropped } : {}
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Text search over events.
1663
+ *
1664
+ * Runs on items only. An occurrence carries no text of its own, so matching
1665
+ * the series once is what a search result should be — expanding it here would
1666
+ * bury one answer under fifty identical ones.
1667
+ */
1668
+ searchEvents(args) {
1669
+ const store = this.#require();
1670
+ const now = this.#now();
1671
+ const fromApple = args.from ? this.#toApple(parseRange({
1672
+ from: args.from,
1673
+ to: args.from,
1674
+ defaultRangeDays: 1,
1675
+ maxRangeDays: 1
1676
+ }, now).from, store) : Number.MIN_SAFE_INTEGER;
1677
+ const toApple = args.to ? this.#toApple(parseRange({
1678
+ from: args.to,
1679
+ to: args.to,
1680
+ defaultRangeDays: 1,
1681
+ maxRangeDays: 1
1682
+ }, now).to, store) : Number.MAX_SAFE_INTEGER;
1683
+ const calendarUuids = this.#calendarUuids(store, args.calendar);
1684
+ const rows = store.searchItems({
1685
+ fromApple,
1686
+ toApple,
1687
+ limit: Math.min(args.limit * 4 + 50, 5e3),
1688
+ text: args.query,
1689
+ scope: args.scope ?? "summary",
1690
+ ...calendarUuids ? { calendarUuids } : {}
1691
+ });
1692
+ const declined = args.includeDeclined ?? this.config.includeDeclined;
1693
+ const cancelled = args.includeCancelled ?? this.config.includeCancelled;
1694
+ return {
1695
+ events: rows.filter((r) => this.#visible(r, {
1696
+ declined,
1697
+ cancelled
1698
+ })).slice(0, args.limit).map((r) => this.#summarise(r, store)),
1699
+ expansion: "unavailable",
1700
+ expansionReason: "search matches each event once, at its series start; use apple_calendar_list_events with a date range to see individual occurrences",
1701
+ window: {
1702
+ from: args.from ? this.#fromApple(fromApple, store) : "unbounded",
1703
+ to: args.to ? this.#fromApple(toApple, store) : "unbounded",
1704
+ clamped: false
1705
+ },
1706
+ coverage: null
1707
+ };
1708
+ }
1709
+ getEvent(ref) {
1710
+ const store = this.#require();
1711
+ const decoded = decodeRef(ref);
1712
+ const row = store.byUuid(decoded.eventUid);
1713
+ if (!row) throw new EventNotFoundError(ref);
1714
+ const shaped = decoded.occurrenceStart ? {
1715
+ ...row,
1716
+ startApple: this.#toApple(decoded.occurrenceStart, store),
1717
+ endApple: row.startApple !== null && row.endApple !== null ? this.#toApple(decoded.occurrenceStart, store) + (row.endApple - row.startApple) : row.endApple,
1718
+ source: "occurrence"
1719
+ } : row;
1720
+ const summary = this.#summarise(shaped, store);
1721
+ return {
1722
+ ...summary,
1723
+ ref,
1724
+ ...decoded.isOccurrence ? { seriesRef: seriesRefOf(decoded) } : {},
1725
+ description: row.description,
1726
+ url: row.url,
1727
+ conferenceUrl: row.conferenceUrl,
1728
+ hasAttendees: row.hasAttendees,
1729
+ hasRecurrences: row.hasRecurrences,
1730
+ timeZone: summary.start && !summary.start.allDay ? summary.start.timeZone : null
1731
+ };
1732
+ }
1733
+ calendars() {
1734
+ return this.#require().calendars();
1735
+ }
1736
+ accounts() {
1737
+ return this.#require().accounts();
1738
+ }
1739
+ /**
1740
+ * Resolve the calendar a write targets, and refuse a read-only one up front.
1741
+ *
1742
+ * Asking the store first means the refusal names the cause. Letting the Apple
1743
+ * Event fail instead produces a message from deep inside Calendar that does
1744
+ * not mention writability at all.
1745
+ */
1746
+ #writeTarget(named) {
1747
+ const store = this.index();
1748
+ const wanted = named ?? this.config.defaultCalendar;
1749
+ if (!store) return {
1750
+ name: wanted,
1751
+ uuid: null
1752
+ };
1753
+ const all = store.calendars();
1754
+ if (!wanted) return {
1755
+ name: void 0,
1756
+ uuid: null
1757
+ };
1758
+ const lower = wanted.toLowerCase();
1759
+ const hits = all.filter((c) => c.uuid?.toLowerCase() === lower || c.title?.toLowerCase() === lower);
1760
+ if (!hits.length) throw new CalendarNotFoundError(wanted, all.map((c) => c.title ?? "").filter(Boolean));
1761
+ /**
1762
+ * Duplicate titles are real: the machine this was measured on has two
1763
+ * calendars both named `olouvignes@me.com`. A read can legitimately span
1764
+ * both, but a write has to land in exactly one — and only a NAME crosses to
1765
+ * Apple Events, so an ambiguous one is refused rather than resolved by coin
1766
+ * flip.
1767
+ */
1768
+ if (hits.length > 1) throw new PreconditionError(`"${wanted}" matches ${hits.length} calendars, and a write has to name exactly one. Calendars are addressed by name when writing — Apple Events cannot resolve a calendar uid at all — so duplicates cannot be told apart. Rename one in Calendar.app, or write to a calendar whose name is unique.`, {
1769
+ requested: wanted,
1770
+ matches: hits.length
1771
+ });
1772
+ const hit = hits[0];
1773
+ if (hit.isSubscribed) throw new CalendarNotWritableError(hit.title ?? wanted);
1774
+ return {
1775
+ name: hit.title ?? wanted,
1776
+ uuid: hit.uuid
1777
+ };
1778
+ }
1779
+ /** Map a calendar name reported by Apple Events back to its store uuid. */
1780
+ #calendarUuidByName(name) {
1781
+ if (!name) return null;
1782
+ const store = this.index();
1783
+ if (!store) return null;
1784
+ return store.calendars().find((c) => c.title === name)?.uuid ?? null;
1785
+ }
1786
+ /**
1787
+ * The calendar NAME a ref points at, for a write.
1788
+ *
1789
+ * A ref carries the store uuid, which Apple Events cannot resolve, so it has
1790
+ * to be turned back into a name before it crosses.
1791
+ */
1792
+ #nameForRefCalendar(calendarUid) {
1793
+ if (!calendarUid) return void 0;
1794
+ const store = this.index();
1795
+ if (!store) return void 0;
1796
+ const lower = calendarUid.toLowerCase();
1797
+ return store.calendars().find((c) => c.uuid?.toLowerCase() === lower)?.title ?? void 0;
1798
+ }
1799
+ /**
1800
+ * Run a write script, re-inflating its application-level failures.
1801
+ *
1802
+ * Core turns a `{ok:false, error:{code, message}}` envelope into a generic
1803
+ * `ProtocolError` carrying the code, which surfaces as a bare `"Birthdays"`.
1804
+ * That matters more here than it looks: the store-side writability check is
1805
+ * derived from `subcal_url` and MISSES calendars that are read-only for other
1806
+ * reasons — Birthdays and Siri Suggestions both report `writable() === false`
1807
+ * while carrying no subscription URL. The JXA lane catches them correctly, so
1808
+ * the only thing lost was the explanation. This puts it back.
1809
+ */
1810
+ async #run(scriptText, params) {
1811
+ try {
1812
+ return await withBusyRetry(() => this.runner.run(scriptText, params));
1813
+ } catch (err) {
1814
+ const code = err?.details?.code;
1815
+ const message = err instanceof Error ? err.message : String(err);
1816
+ if (code === "CALENDAR_NOT_WRITABLE") throw new CalendarNotWritableError(message);
1817
+ if (code === "CALENDAR_NOT_FOUND") throw new CalendarNotFoundError(message);
1818
+ if (code === "EVENT_NOT_FOUND") throw new EventNotFoundError(message);
1819
+ throw err;
1820
+ }
1821
+ }
1822
+ #shapeWrite(data, calendarUid) {
1823
+ const uid = typeof data.uid === "string" ? data.uid : null;
1824
+ return {
1825
+ ref: encodeRef(calendarUid ?? "", uid ?? ""),
1826
+ uid,
1827
+ summary: typeof data.summary === "string" ? data.summary : null,
1828
+ start: typeof data.startDate === "string" ? data.startDate : null,
1829
+ end: typeof data.endDate === "string" ? data.endDate : null,
1830
+ allDay: data.alldayEvent === true,
1831
+ calendar: typeof data.calendarName === "string" ? data.calendarName : null,
1832
+ source: "apple-events"
1833
+ };
1834
+ }
1835
+ /**
1836
+ * Work out an event's end from whichever of the three forms the caller used.
1837
+ *
1838
+ * `end` wins over `durationMinutes`; with neither, the configured default
1839
+ * length applies. A zero-length event is refused rather than created, because
1840
+ * Calendar renders one as a point in time that is almost impossible to click.
1841
+ */
1842
+ #resolveWindow(start, end, durationMinutes) {
1843
+ const now = this.#now();
1844
+ const from = parseDate("start", start, now);
1845
+ if (end !== void 0) {
1846
+ /**
1847
+ * A bare day as `end` means THROUGH that day, not midnight at its start.
1848
+ *
1849
+ * `parseBound` already encodes this for range queries, and list_events
1850
+ * documents it — "naming the same day for both gives that whole day". Using
1851
+ * plain `parseDate` here made create disagree with list about the same
1852
+ * word: `{start: "2026-09-21", end: "2026-09-21"}`, the natural way to say
1853
+ * "a one-day event", was refused as ending before it began.
1854
+ */
1855
+ const to = parseBound("end", end, "end", now);
1856
+ if (to.getTime() <= from.at.getTime()) throw new PreconditionError(`end (${toLocalIso(to)}) is not after start (${from.iso}). An event cannot finish before it begins.`);
1857
+ return {
1858
+ startIso: from.iso,
1859
+ endIso: toLocalIso(to),
1860
+ allDayHint: from.kind === "allDay"
1861
+ };
1862
+ }
1863
+ const minutes = durationMinutes === void 0 ? this.config.defaultEventDurationMinutes : parseDuration("durationMinutes", durationMinutes);
1864
+ const to = new Date(from.at.getTime() + minutes * 6e4);
1865
+ return {
1866
+ startIso: from.iso,
1867
+ endIso: toLocalIso(to),
1868
+ allDayHint: from.kind === "allDay"
1869
+ };
1870
+ }
1871
+ async createEvent(fields) {
1872
+ const target = this.#writeTarget(fields.calendar);
1873
+ const win = this.#resolveWindow(fields.start, fields.end, fields.durationMinutes);
1874
+ const data = await this.#run(CREATE_EVENT, {
1875
+ calendar: target.name ?? null,
1876
+ summary: fields.summary,
1877
+ startDate: win.startIso,
1878
+ endDate: win.endIso,
1879
+ allDay: fields.allDay ?? win.allDayHint,
1880
+ ...fields.location !== void 0 ? { location: fields.location } : {},
1881
+ ...fields.description !== void 0 ? { description: fields.description } : {},
1882
+ ...fields.url !== void 0 ? { url: fields.url } : {}
1883
+ });
1884
+ this.invalidate();
1885
+ const uuid = target.uuid ?? this.#calendarUuidByName(typeof data.calendarName === "string" ? data.calendarName : null);
1886
+ return this.#shapeWrite(data, uuid);
1887
+ }
1888
+ async updateEvent(fields) {
1889
+ const ref = decodeRef(fields.ref);
1890
+ /**
1891
+ * An occurrence ref is REFUSED rather than quietly applied to the series.
1892
+ *
1893
+ * Calendar's scripting dictionary has no way to detach a single occurrence —
1894
+ * the "This Event" edit in the UI has no scripting equivalent. Applying the
1895
+ * change to the series would move every future standup because someone
1896
+ * asked to move one lunch, which is a data-loss bug wearing a success
1897
+ * message. "All future" is absent for a related reason: it needs a rule
1898
+ * split, which is two writes with no transaction between them.
1899
+ */
1900
+ if (ref.isOccurrence) throw new PreconditionError("That ref names one occurrence of a repeating event, and Calendar's scripting interface cannot edit a single occurrence — only the whole series. Applying your change to the series would move every other occurrence too, so this is refused rather than done silently. To change just this one: delete it with apple_calendar_delete_events (scope \"occurrence\"), then create a replacement. To change them all, pass the seriesRef from apple_calendar_get_event instead.", {
1901
+ ref: fields.ref,
1902
+ seriesRef: seriesRefOf(ref)
1903
+ });
1904
+ const window = fields.start !== void 0 ? this.#resolveWindow(fields.start, fields.end, fields.durationMinutes) : null;
1905
+ const data = await this.#run(UPDATE_EVENT, {
1906
+ calendar: this.#nameForRefCalendar(ref.calendarUid) ?? null,
1907
+ uid: ref.eventUid,
1908
+ ...fields.summary !== void 0 ? { summary: fields.summary } : {},
1909
+ ...window ? {
1910
+ startDate: window.startIso,
1911
+ endDate: window.endIso
1912
+ } : {},
1913
+ ...fields.allDay !== void 0 ? { allDay: fields.allDay } : {},
1914
+ ...fields.location !== void 0 ? { location: fields.location } : {},
1915
+ ...fields.description !== void 0 ? { description: fields.description } : {},
1916
+ ...fields.url !== void 0 ? { url: fields.url } : {}
1917
+ });
1918
+ this.invalidate();
1919
+ return this.#shapeWrite(data, ref.calendarUid);
1920
+ }
1921
+ /**
1922
+ * Delete whole events.
1923
+ *
1924
+ * Only whole events: Calendar's scripting interface cannot remove a single
1925
+ * occurrence of a repeating one. `excludedDates` — the property Calendar.app
1926
+ * itself uses for "Delete This Event" — reads back a 1903 sentinel and throws
1927
+ * on assignment, measured on macOS 26.6. So an occurrence ref is refused
1928
+ * rather than silently deleting the whole series, which is the same shape of
1929
+ * refusal `updateEvent` makes and for the same underlying reason.
1930
+ */
1931
+ async deleteEvents(refs) {
1932
+ const decoded = refs.map((r, i) => ({
1933
+ ref: refs[i],
1934
+ parsed: decodeRef(r)
1935
+ }));
1936
+ const occurrence = decoded.find((d) => d.parsed.isOccurrence);
1937
+ if (occurrence) throw new PreconditionError("That ref names one occurrence of a repeating event, and Calendar's scripting interface cannot delete a single occurrence — the excluded-dates property it would need is not writable. Deleting the series instead would remove every other occurrence too, so this is refused rather than done silently. Delete this one in Calendar.app, or pass the seriesRef from apple_calendar_get_event to delete the whole series.", {
1938
+ ref: occurrence.ref,
1939
+ seriesRef: seriesRefOf(occurrence.parsed)
1940
+ });
1941
+ const byCalendar = /* @__PURE__ */ new Map();
1942
+ for (const { parsed } of decoded) {
1943
+ const key = parsed.calendarUid || "";
1944
+ byCalendar.set(key, [...byCalendar.get(key) ?? [], parsed.eventUid]);
1945
+ }
1946
+ const results = [];
1947
+ for (const [calendar, uids] of byCalendar) {
1948
+ const data = await this.#run(DELETE_EVENTS, {
1949
+ calendar: this.#nameForRefCalendar(calendar) ?? null,
1950
+ uids
1951
+ });
1952
+ results.push(...data.results ?? []);
1953
+ }
1954
+ this.invalidate();
1955
+ return {
1956
+ results,
1957
+ scope: "series"
1958
+ };
1959
+ }
1960
+ lanes() {
1961
+ const located = this.locate();
1962
+ const store = this.index();
1963
+ return {
1964
+ applescript: "not-used",
1965
+ index: this.config.indexMode === "off" ? "disabled" : store ? "live" : "unavailable",
1966
+ indexMode: store?.mode ?? null,
1967
+ storeFingerprint: store?.caps.fingerprint ?? null,
1968
+ reason: store ? null : located.reason
1969
+ };
1970
+ }
1971
+ };
1972
+ //#endregion
1973
+ //#region src/config.ts
1974
+ /**
1975
+ * Configuration is environment-only — this server holds no secret at all, its
1976
+ * access is the macOS permission the user granted.
1977
+ *
1978
+ * `allowWrites`, `debug`, `osascriptPath`, `osascriptTimeoutMs` and `maxResults`
1979
+ * come from `BaseConfigSchema`.
1980
+ *
1981
+ * Note what is deliberately ABSENT relative to `packages/reminders`: there is no
1982
+ * `searchCacheTtlMs` and no degraded-listing cap, because both exist there to
1983
+ * manage an Apple Events READ lane. Calendar has none by design
1984
+ * (`docs/distribution.md`), and config for a lane that does not exist would
1985
+ * advertise a fallback this server cannot provide.
1986
+ */
1987
+ const ConfigSchema = BaseConfigSchema.extend({
1988
+ /** Account allowlist (names or ids). Empty means every account. */
1989
+ accounts: z.array(z.string().min(1)).default([]),
1990
+ /**
1991
+ * Calendar allowlist (names or uids). Empty means every calendar.
1992
+ *
1993
+ * The important one for this surface: a work calendar and a personal one
1994
+ * routinely live in the same account, so the account is the wrong unit to
1995
+ * scope by whenever scoping is the point.
1996
+ */
1997
+ calendars: z.array(z.string().min(1)).default([]),
1998
+ /** Explicit store path. Bypasses discovery — for tests and forensic copies. */
1999
+ storePath: z.string().optional(),
2000
+ indexMode: z.enum([
2001
+ "auto",
2002
+ "ro",
2003
+ "immutable",
2004
+ "off"
2005
+ ]).default("auto"),
2006
+ /** Calendar a new event goes to when the caller names none. Empty = Calendar's default. */
2007
+ defaultCalendar: z.string().optional(),
2008
+ /**
2009
+ * Window for a range query that names only a start.
2010
+ *
2011
+ * A calendar has no natural "everything" answer the way a note list does, and
2012
+ * an unbounded default would scan a decade to report next Tuesday.
2013
+ */
2014
+ defaultRangeDays: z.number().int().min(1).max(366).default(7),
2015
+ /** Hard clamp, so one query cannot ask for a decade. */
2016
+ maxRangeDays: z.number().int().min(1).max(3660).default(366),
2017
+ /** Length of a created event when the caller gives neither an end nor a duration. */
2018
+ defaultEventDurationMinutes: z.number().int().min(1).max(1440).default(60),
2019
+ /** Whether events the user declined are included when the caller does not say. */
2020
+ includeDeclined: z.boolean().default(false),
2021
+ /** Whether events an organiser cancelled are included when the caller does not say. */
2022
+ includeCancelled: z.boolean().default(false),
2023
+ /**
2024
+ * Render override for timed events. Empty means the system zone.
2025
+ *
2026
+ * Validated as a real IANA name here rather than at render time: a bad zone
2027
+ * should fail at startup with the variable named, not once per event.
2028
+ */
2029
+ timeZone: z.string().refine((v) => {
2030
+ try {
2031
+ Intl.DateTimeFormat("en-US", { timeZone: v });
2032
+ return true;
2033
+ } catch {
2034
+ return false;
2035
+ }
2036
+ }, { message: "not an IANA time zone name, e.g. \"Europe/Paris\"" }).optional()
2037
+ }).strict();
2038
+ /**
2039
+ * `env` is a parameter with a default so tests are hermetic — they pass their
2040
+ * own object rather than mutating (and having to restore) process.env.
2041
+ */
2042
+ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
2043
+ allowWrites: parseBool(env.APPLE_CALENDAR_ALLOW_WRITES),
2044
+ debug: parseBool(env.APPLE_CALENDAR_DEBUG),
2045
+ accounts: parseList(env.APPLE_CALENDAR_ACCOUNTS),
2046
+ calendars: parseList(env.APPLE_CALENDAR_CALENDARS),
2047
+ storePath: trimmed(env.APPLE_CALENDAR_STORE),
2048
+ indexMode: trimmed(env.APPLE_CALENDAR_INDEX_MODE),
2049
+ defaultCalendar: trimmed(env.APPLE_CALENDAR_DEFAULT_CALENDAR),
2050
+ defaultRangeDays: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_RANGE_DAYS),
2051
+ maxRangeDays: parseIntOpt(env.APPLE_CALENDAR_MAX_RANGE_DAYS),
2052
+ defaultEventDurationMinutes: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_EVENT_DURATION_MINUTES),
2053
+ includeDeclined: parseBool(env.APPLE_CALENDAR_INCLUDE_DECLINED),
2054
+ includeCancelled: parseBool(env.APPLE_CALENDAR_INCLUDE_CANCELLED),
2055
+ osascriptPath: trimmed(env.APPLE_CALENDAR_OSASCRIPT_PATH),
2056
+ osascriptTimeoutMs: parseIntOpt(env.APPLE_CALENDAR_OSASCRIPT_TIMEOUT_MS),
2057
+ maxResults: parseIntOpt(env.APPLE_CALENDAR_MAX_RESULTS),
2058
+ timeZone: trimmed(env.APPLE_CALENDAR_TIMEZONE)
2059
+ });
2060
+ //#endregion
2061
+ //#region src/tools/util.ts
2062
+ const eventRefArg = z.string().min(1).describe("An opaque event ref from a list or search result (looks like \"c1:<calendar>/<occurrence>/<uid>\"). Do not construct one by hand.");
2063
+ const calendarArg = z.string().optional().describe("A calendar name or uid, e.g. \"Work\". Use apple_calendar_list_calendars to see what exists.");
2064
+ /**
2065
+ * The date grammar, written out in full.
2066
+ *
2067
+ * Load-bearing: the caller is usually a model that gets one retry, and the
2068
+ * difference between naming a day and naming an instant is the difference
2069
+ * between a whole-day range and a one-minute one.
2070
+ */
2071
+ const fromArg = z.string().optional().describe("Start of the window. ISO-8601 \"2026-08-21\" or \"2026-08-21T09:00\", or \"today\", \"tomorrow\", \"next monday\", \"+2d\". A bare day starts at that morning. Defaults to today.");
2072
+ const toArg = z.string().optional().describe("End of the window. A bare day runs through that EVENING, so naming the same day for both gives that whole day. Defaults to a week after the start.");
2073
+ const includeDeclinedArg = z.boolean().optional().describe("Include events you have declined. Off by default; they are still on the calendar.");
2074
+ const includeCancelledArg = z.boolean().optional().describe("Include events an organiser cancelled but that are still in the store. Off by default.");
2075
+ //#endregion
2076
+ //#region src/tools/actions.ts
2077
+ /**
2078
+ * The mutating tools.
2079
+ *
2080
+ * Registered only when `allowWrites` is on — not merely refused, absent, so a
2081
+ * host is never told they exist.
2082
+ *
2083
+ * ## No `attendees` parameter, anywhere
2084
+ *
2085
+ * Adding an attendee sends an email to a person. That is not something to do
2086
+ * behind a tool call, and leaving it out of the schema is a stronger guarantee
2087
+ * than validating it away. `test/jxa.test.ts` asserts the scripts cannot set it
2088
+ * either, so there are two places to remove rather than one to forget.
2089
+ */
2090
+ const startArg = z.string().describe("When it starts. ISO-8601 \"2026-08-21\" for an all-day event or \"2026-08-21T09:00\" for a timed one, or \"tomorrow 09:00\", \"next monday\", \"+2d\". Naming a bare day makes it all-day.");
2091
+ const endArg = z.string().optional().describe("When it ends. Give this or durationMinutes; with neither, the default length applies.");
2092
+ const durationArg = z.number().int().min(1).max(527040).optional().describe("Length in minutes, as an alternative to `end`.");
2093
+ const registerActionTools = (server, client) => {
2094
+ server.registerTool("apple_calendar_create_event", {
2095
+ description: "Create an event. THIS IS A REAL EVENT ON A REAL CALENDAR: on an iCloud, CalDAV or Exchange calendar it syncs within seconds and there is no draft state and no undo. Check apple_calendar_list_calendars first — writing to one where `isShared` is true is visible to everyone else on that calendar, so prefer a personal one unless the user meant to share it. Read-only calendars are refused. This tool cannot add attendees, which would email a person. The result reports what Calendar actually stored, which is not always what was asked for.",
2096
+ inputSchema: {
2097
+ summary: z.string().min(1).describe("The event's title."),
2098
+ calendar: calendarArg,
2099
+ start: startArg,
2100
+ end: endArg,
2101
+ durationMinutes: durationArg,
2102
+ allDay: z.boolean().optional().describe("Force an all-day event. Usually unnecessary: a bare day in `start` means one."),
2103
+ location: z.string().optional(),
2104
+ description: z.string().optional().describe("The event's notes."),
2105
+ url: z.string().optional()
2106
+ }
2107
+ }, async (args) => wrap(() => client.createEvent(args)));
2108
+ server.registerTool("apple_calendar_update_event", {
2109
+ description: "Change an existing event. On a shared calendar the change is visible to everyone else on it. Note that Apple Events has no transaction: if a change is refused part-way through, earlier fields may already have been written, so the result is the truth about what the event now looks like. Only whole events can be edited: a ref naming ONE occurrence of a repeating event is refused, because Calendar's scripting interface cannot detach a single occurrence and applying the change to the series would move every other one too. To change a single occurrence, delete it with scope \"occurrence\" and create a replacement.",
2110
+ inputSchema: {
2111
+ ref: eventRefArg,
2112
+ summary: z.string().min(1).optional(),
2113
+ start: startArg.optional(),
2114
+ end: endArg,
2115
+ durationMinutes: durationArg,
2116
+ allDay: z.boolean().optional(),
2117
+ location: z.string().optional(),
2118
+ description: z.string().optional(),
2119
+ url: z.string().optional()
2120
+ }
2121
+ }, async (args) => wrap(() => client.updateEvent(args)));
2122
+ server.registerTool("apple_calendar_delete_events", {
2123
+ description: "Delete whole events. Each result says whether the event ACTUALLY went — Calendar silently declines to delete a repeating event, reporting no error, so `deleted` is decided by re-reading the calendar rather than by the call succeeding. A ref naming ONE occurrence of a repeating event is refused: Calendar's scripting interface cannot remove a single occurrence, and deleting the series instead would take every other one with it. Delete a single occurrence in Calendar.app, or pass the `seriesRef` from get_event to remove the whole series. \"All future occurrences\" is not offered either — it needs two writes with no transaction between them.",
2124
+ inputSchema: {
2125
+ refs: z.array(z.string().min(1)).min(1).max(100).describe("Event refs from a list or search result. Each must name a whole event, not one occurrence of a repeating one."),
2126
+ confirm: confirmArg
2127
+ }
2128
+ }, async ({ refs }) => wrap(() => client.deleteEvents(refs)));
2129
+ };
2130
+ //#endregion
2131
+ //#region src/tools/calendars.ts
2132
+ /**
2133
+ * NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because every
2134
+ * other surface reaches Apple Events. Calendar reads synchronous SQLite, so the
2135
+ * thunks are marked async here rather than widening a shared signature for all
2136
+ * four surfaces to accommodate one.
2137
+ */
2138
+ const registerCalendarTools = (server, client) => {
2139
+ server.registerTool("apple_calendar_list_calendars", {
2140
+ description: "List every calendar with the account it belongs to, whether it is SHARED with other people, and whether it looks writable. Two cautions. `isShared` means anything you write there is visible to the others on it. And `isSubscribed` catches URL-subscribed calendars but not every read-only one — Birthdays and Siri Suggestions are also read-only — so a write can still be refused by Calendar itself.",
2141
+ inputSchema: {},
2142
+ annotations: { readOnlyHint: true }
2143
+ }, async () => wrap(async () => client.calendars()));
2144
+ server.registerTool("apple_calendar_list_accounts", {
2145
+ description: "List the accounts Calendar syncs, with how many calendars each holds. Use this to scope a search when the same calendar name exists in more than one account.",
2146
+ inputSchema: {},
2147
+ annotations: { readOnlyHint: true }
2148
+ }, async () => wrap(async () => client.accounts()));
2149
+ };
2150
+ //#endregion
2151
+ //#region src/tools/diagnostics.ts
2152
+ /**
2153
+ * One tool that answers "why is this not working".
2154
+ *
2155
+ * Unlike the Reminders equivalent this probes no Apple Event before reading:
2156
+ * Calendar has no Apple Events read lane to probe (`docs/distribution.md`), so
2157
+ * firing one here would trigger the Automation prompt for a capability the
2158
+ * server does not yet have.
2159
+ */
2160
+ const registerDiagnosticsTools = (server, client, ctx) => {
2161
+ server.registerTool("apple_calendar_diagnostics", {
2162
+ description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool fails or reports nothing — it names the exact System Settings pane to open.",
2163
+ inputSchema: {},
2164
+ annotations: { readOnlyHint: true }
2165
+ }, async () => wrap(async () => {
2166
+ const lanes = client.lanes();
2167
+ const located = client.locate();
2168
+ const store = client.index();
2169
+ /**
2170
+ * Three-valued, and the middle value is the useful one.
2171
+ *
2172
+ * Calendar's store has a constant filename, so `stat` answers "is it
2173
+ * there" even when `access(2)` is denied. That lets this separate "the
2174
+ * grant is missing" from "Calendar was never set up on this account",
2175
+ * which Reminders cannot do — its filename carries a generated UUID, so
2176
+ * without the grant there is no path to test at all.
2177
+ */
2178
+ const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied (found the store, cannot read it)" : "unknown (no store file at the expected path)";
2179
+ return {
2180
+ server: { lanes },
2181
+ permissions: {
2182
+ fullDiskAccess,
2183
+ automation: ctx.allowWrites ? "needed for writes only. Reads never send an Apple Event, so a read-only setup prompts for nothing." : "not needed — writes are off, and reads never send an Apple Event. Turning writes on will prompt for Automation the first time one runs.",
2184
+ ...located.readable ? {} : { howToGrant: [
2185
+ "System Settings > Privacy & Security > Full Disk Access",
2186
+ "Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
2187
+ "Granting it to Calendar.app does nothing — the reader needs the permission, not Calendar."
2188
+ ] }
2189
+ },
2190
+ store: {
2191
+ containerPath: located.containerPath,
2192
+ path: located.storePath,
2193
+ containerListable: located.containerListable,
2194
+ extrasPresent: located.extrasPresent,
2195
+ candidates: located.candidates.length,
2196
+ exists: located.exists,
2197
+ readable: located.readable,
2198
+ sizeBytes: located.size,
2199
+ walPresent: located.walPresent,
2200
+ walSizeBytes: located.walSizeBytes,
2201
+ fingerprint: lanes.storeFingerprint,
2202
+ reason: located.reason
2203
+ },
2204
+ capabilities: store ? {
2205
+ hasOccurrenceCache: store.caps.hasOccurrenceCache,
2206
+ hasOccurrenceDays: store.caps.hasOccurrenceDays,
2207
+ hasRecurrence: store.caps.hasRecurrence,
2208
+ hasExceptionDates: store.caps.hasExceptionDates,
2209
+ hasLocation: store.caps.hasLocation,
2210
+ hasAttachments: store.caps.hasAttachments,
2211
+ hasParticipants: store.caps.hasParticipants,
2212
+ hasAlarms: store.caps.hasAlarms,
2213
+ itemColumns: store.caps.itemColumns.size,
2214
+ calendarColumns: store.caps.calendarColumns.size
2215
+ } : null,
2216
+ settings: {
2217
+ allowWrites: ctx.allowWrites,
2218
+ accountAllowlist: client.config.accounts,
2219
+ calendarAllowlist: client.config.calendars,
2220
+ defaultCalendar: client.config.defaultCalendar ?? null,
2221
+ defaultRangeDays: client.config.defaultRangeDays,
2222
+ maxRangeDays: client.config.maxRangeDays,
2223
+ includeDeclined: client.config.includeDeclined,
2224
+ includeCancelled: client.config.includeCancelled,
2225
+ indexMode: client.config.indexMode,
2226
+ maxResults: client.config.maxResults,
2227
+ timeZone: client.config.timeZone ?? null
2228
+ },
2229
+ caveats: [
2230
+ "Writes go through Apple Events and are real side effects: on a shared, CalDAV or Exchange calendar a created event syncs within seconds and other people see it. There is no draft state and no undo.",
2231
+ "Single occurrences of a repeating event can be neither edited nor deleted through this server, and that is a limit of Calendar's scripting interface rather than a choice: there is no way to detach one occurrence, and the excluded-dates property reads back a 1903 sentinel and throws on assignment (measured, macOS 26.6). Both are refused rather than applied to the whole series. Calendar.app can still do them.",
2232
+ "Attendees cannot be set, because that emails a person. There is no \"all future occurrences\" either, which would need two writes with no transaction between them.",
2233
+ "Calendar reads through the file lane only. Apple Events was measured at 3.4s for a single 90-day range query, with the cost falling per round trip rather than per event, so there is no slower-but-working fallback to offer when Full Disk Access is missing. Reads need the grant.",
2234
+ "Repeating events are expanded from OccurrenceCache, which was measured to reach about two years either side of today on the probed store. That is an edge: a range running past it returns fewer repeating events than exist, so every list_events result carries `coverage`, and sets `truncated` rather than returning a short list silently.",
2235
+ "The `status` and `invitationStatus` numbers are EventKit's documented constants, and this store is assumed to mirror them — likely, but not measured. That is why cancelled and declined events are only hidden when you ask, and why the raw value is on every result.",
2236
+ "Writes will go through Apple Events, targeting com.apple.iCal. Note the bundle id is iCal, not Calendar, which is the one place this surface differs from the other three."
2237
+ ]
2238
+ };
2239
+ }).then((r) => r ?? ok({})));
2240
+ };
2241
+ //#endregion
2242
+ //#region src/tools/events.ts
2243
+ /** Declared once and spread into both tools, so the two cannot drift apart. */
2244
+ const filterSchema = {
2245
+ calendar: calendarArg,
2246
+ includeDeclined: includeDeclinedArg,
2247
+ includeCancelled: includeCancelledArg,
2248
+ limit: limitArg
2249
+ };
2250
+ const registerEventTools = (server, client) => {
2251
+ server.registerTool("apple_calendar_list_events", {
2252
+ description: "List events in a date range, earliest first. Occurrences of repeating events are expanded, so a weekly standup appears once per week rather than once. The result carries a `coverage` block naming the window the expansion is known to cover; if your range runs past it, `truncated` is set and says what is missing rather than returning a short list silently. Returns a `ref` per event for get_event.",
2253
+ inputSchema: {
2254
+ from: fromArg,
2255
+ to: toArg,
2256
+ ...filterSchema
2257
+ },
2258
+ annotations: { readOnlyHint: true }
2259
+ }, async (args) => wrap(async () => client.listEvents({
2260
+ ...args,
2261
+ limit: args.limit ?? Math.min(50, client.config.maxResults)
2262
+ })));
2263
+ server.registerTool("apple_calendar_search_events", {
2264
+ description: "Search events by text, most recent first. Unbounded in time unless you give `from` or `to`. Matches each event ONCE at its series start — it does not expand occurrences, so use list_events with a range to see individual instances of a repeating event.",
2265
+ inputSchema: {
2266
+ query: z.string().min(1).describe("Text to look for. Matched case-insensitively."),
2267
+ scope: z.enum(["summary", "full"]).optional().describe("\"summary\" (default) searches titles; \"full\" adds notes and location."),
2268
+ from: fromArg,
2269
+ to: toArg,
2270
+ ...filterSchema
2271
+ },
2272
+ annotations: { readOnlyHint: true }
2273
+ }, async (args) => wrap(async () => client.searchEvents({
2274
+ ...args,
2275
+ limit: args.limit ?? Math.min(50, client.config.maxResults)
2276
+ })));
2277
+ server.registerTool("apple_calendar_get_event", {
2278
+ description: "Full detail for one event: notes, location, URL, conference link, and whether it has attendees or repeats. A ref naming one occurrence reports that occurrence's times and carries a `seriesRef` pointing at the whole series.",
2279
+ inputSchema: { ref: eventRefArg },
2280
+ annotations: { readOnlyHint: true }
2281
+ }, async ({ ref }) => wrap(async () => client.getEvent(ref)));
2282
+ };
2283
+ //#endregion
2284
+ //#region src/tools/index.ts
2285
+ /**
2286
+ * Register the Apple Calendar tools.
2287
+ *
2288
+ * The registered set is a pure function of `allowWrites` and nothing else. In
2289
+ * particular it does NOT vary with whether Full Disk Access is granted: that is
2290
+ * a runtime condition which can change while the process lives, and MCP clients
2291
+ * cache the tool list, so a tool that appears and disappears would leave clients
2292
+ * calling names the server no longer has. Tools that need the store instead
2293
+ * report their source, or explain what is missing.
2294
+ *
2295
+ * Writes go through Apple Events, always. Not a preference: `PRAGMA query_only`
2296
+ * is set on the store because Calendar owns it, holds it open and reconciles it
2297
+ * against a server, so writing to it would corrupt sync state.
2298
+ */
2299
+ const registerTools = (server, client, ctx) => {
2300
+ registerDiagnosticsTools(server, client, ctx);
2301
+ registerCalendarTools(server, client);
2302
+ registerEventTools(server, client);
2303
+ if (!ctx.allowWrites) return;
2304
+ registerActionTools(server, client);
2305
+ };
2306
+ //#endregion
2307
+ //#region src/server.ts
2308
+ const SERVER_NAME = BUILD_INFO.name;
2309
+ const SERVER_VERSION = BUILD_INFO.version;
2310
+ /**
2311
+ * Build the server. Side-effect free: it opens no connection, spawns no
2312
+ * process and reads no file, so a test can construct it freely and every
2313
+ * external dependency arrives through an option.
2314
+ *
2315
+ */
2316
+ const createServer = (opts) => {
2317
+ const { config } = opts;
2318
+ const server = new McpServer({
2319
+ name: SERVER_NAME,
2320
+ version: SERVER_VERSION
2321
+ });
2322
+ const client = new AppleCalendarClient({
2323
+ config,
2324
+ ...opts.logger ? { logger: opts.logger } : {},
2325
+ ...opts.osascript ? { osascript: opts.osascript } : {},
2326
+ ...opts.now ? { now: opts.now } : {}
2327
+ });
2328
+ registerTools(server, client, { allowWrites: config.allowWrites });
2329
+ return {
2330
+ server,
2331
+ client
2332
+ };
2333
+ };
2334
+ //#endregion
2335
+ export { BUILD_INFO as A, CALENDAR_SURFACE as C, CalendarNotWritableError as D, CalendarNotRunningError as E, EventNotFoundError as O, CALENDAR_BUNDLE_ID as S, CalendarNotFoundError as T, STORE_FILENAME as _, loadConfig as a, locateStore as b, introspect as c, decodeRef as d, encodeRef as f, GROUP_CONTAINER as g, EXTRAS_FILENAME as h, registerTools as i, InvalidDateError as k, openStore as l, uuidOf as m, SERVER_VERSION as n, AppleCalendarClient as o, seriesRefOf as p, createServer as r, CalendarStore as s, SERVER_NAME as t, REF_VERSION as u, defaultContainerPath as v, CalendarBusyError as w, AppleCalendarError as x, defaultStorePath as y };
2336
+
2337
+ //# sourceMappingURL=server-B2HtiXLF.js.map