@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +625 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-B2HtiXLF.js +2337 -0
- package/dist/server-B2HtiXLF.js.map +1 -0
- package/package.json +68 -0
|
@@ -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
|