apple-tools-mcp 1.2.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1367 @@
1
+ /**
2
+ * Calendar write operations: list calendars, add / edit / remove events,
3
+ * RSVP to invitations, plus recurrence rules and alerts.
4
+ *
5
+ * Events are addressed by their iCalendar UID (Calendar.app's `uid`
6
+ * property), which `calendar_date` reports as "Event ID" and `calendar_add`
7
+ * returns for new events.
8
+ */
9
+
10
+ import {
11
+ runAppleScript,
12
+ asString,
13
+ asInteger,
14
+ dateCall,
15
+ parseWriteDateTime,
16
+ DATE_HANDLER,
17
+ CALENDAR_TCC_GUIDANCE,
18
+ ATTRIBUTION_GUIDANCE,
19
+ formatOsascriptDiagnostic,
20
+ isHardTccDenial
21
+ } from "./appleScript.js";
22
+ import {
23
+ planWrite,
24
+ validateEventId,
25
+ validateEventKitId,
26
+ validateCalendarName,
27
+ validateBody,
28
+ validateSubject,
29
+ writeErrorMessage,
30
+ writeSuccessMessage,
31
+ isFlagTrue,
32
+ normalizeList,
33
+ truncate
34
+ } from "./writeGuards.js";
35
+ import {
36
+ ensureEventKitSession,
37
+ getEventKitSession,
38
+ setEventKitSession
39
+ } from "./eventKitSession.js";
40
+
41
+ export const RECURRENCE_FREQUENCIES = ["daily", "weekly", "monthly", "yearly"];
42
+ export const RECURRENCE_DAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
43
+ export const RSVP_RESPONSES = ["accept", "decline", "tentative"];
44
+
45
+ const RSVP_STATUS = {
46
+ accept: "accepted",
47
+ decline: "declined",
48
+ tentative: "tentative"
49
+ };
50
+
51
+ // Alerts are minutes before the event start; capped at four weeks.
52
+ const MAX_ALERT_MINUTES = 40320;
53
+
54
+ /**
55
+ * Build an RFC 5545 RRULE from structured arguments, or validate a raw rule.
56
+ *
57
+ * Supported patterns:
58
+ * FREQ=DAILY|WEEKLY|MONTHLY|YEARLY with optional INTERVAL, COUNT or UNTIL,
59
+ * and BYDAY (weekly).
60
+ *
61
+ * @returns {{ rule: string|null, error: string|null }}
62
+ */
63
+ export function buildRecurrenceRule(options = {}) {
64
+ const { recurrence, frequency, interval, count, until, by_day: byDay } = options;
65
+
66
+ if (recurrence) {
67
+ if (typeof recurrence !== "string") return { rule: null, error: "recurrence must be a string" };
68
+ const raw = recurrence.trim().toUpperCase().replace(/^RRULE:/, "");
69
+ if (!/^FREQ=[A-Z0-9=;,:+-]{1,290}$/.test(raw)) {
70
+ return { rule: null, error: 'recurrence must be an RRULE starting with FREQ= (for example "FREQ=WEEKLY;INTERVAL=1;COUNT=10")' };
71
+ }
72
+ return { rule: raw, error: null };
73
+ }
74
+
75
+ if (!frequency) return { rule: null, error: null };
76
+
77
+ const freq = String(frequency).toLowerCase();
78
+ if (!RECURRENCE_FREQUENCIES.includes(freq)) {
79
+ return { rule: null, error: `frequency must be one of: ${RECURRENCE_FREQUENCIES.join(", ")}` };
80
+ }
81
+
82
+ const parts = [`FREQ=${freq.toUpperCase()}`];
83
+
84
+ if (interval !== undefined && interval !== null && interval !== "") {
85
+ const n = Number(interval);
86
+ if (!Number.isInteger(n) || n < 1 || n > 366) {
87
+ return { rule: null, error: "interval must be an integer between 1 and 366" };
88
+ }
89
+ parts.push(`INTERVAL=${n}`);
90
+ }
91
+
92
+ if (byDay !== undefined && byDay !== null && byDay !== "") {
93
+ const days = normalizeList(byDay).map((d) => d.toUpperCase());
94
+ const bad = days.filter((d) => !RECURRENCE_DAYS.includes(d));
95
+ if (bad.length > 0) {
96
+ return { rule: null, error: `by_day accepts ${RECURRENCE_DAYS.join(", ")}; got ${bad.join(", ")}` };
97
+ }
98
+ if (days.length > 0) parts.push(`BYDAY=${days.join(",")}`);
99
+ }
100
+
101
+ if (count !== undefined && count !== null && count !== "") {
102
+ if (until) return { rule: null, error: "use either count or until, not both" };
103
+ const n = Number(count);
104
+ if (!Number.isInteger(n) || n < 1 || n > 1000) {
105
+ return { rule: null, error: "count must be an integer between 1 and 1000" };
106
+ }
107
+ parts.push(`COUNT=${n}`);
108
+ }
109
+
110
+ if (until) {
111
+ const parsed = parseWriteDateTime(until, "until");
112
+ if (parsed.error) return { rule: null, error: parsed.error };
113
+ const p = parsed.parts;
114
+ const pad = (v) => String(v).padStart(2, "0");
115
+ // UNTIL is expressed in UTC per RFC 5545.
116
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, 0));
117
+ parts.push(
118
+ `UNTIL=${utc.getUTCFullYear()}${pad(utc.getUTCMonth() + 1)}${pad(utc.getUTCDate())}T` +
119
+ `${pad(utc.getUTCHours())}${pad(utc.getUTCMinutes())}00Z`
120
+ );
121
+ }
122
+
123
+ return { rule: parts.join(";"), error: null };
124
+ }
125
+
126
+ /**
127
+ * Validate the alerts list (minutes before start).
128
+ * @returns {{ minutes: number[], error: string|null }}
129
+ */
130
+ export function validateAlerts(value) {
131
+ if (value === undefined || value === null || value === "") return { minutes: [], error: null };
132
+ const entries = normalizeList(value);
133
+ const minutes = [];
134
+ for (const entry of entries) {
135
+ const n = Number(entry);
136
+ if (!Number.isInteger(n) || n < 0 || n > MAX_ALERT_MINUTES) {
137
+ return { minutes: [], error: `alerts_minutes_before accepts whole minutes from 0 to ${MAX_ALERT_MINUTES}; got ${truncate(entry, 40)}` };
138
+ }
139
+ minutes.push(n);
140
+ }
141
+ if (minutes.length > 5) return { minutes: [], error: "at most 5 alerts per event" };
142
+ return { minutes, error: null };
143
+ }
144
+
145
+ /**
146
+ * End time when the caller gave only a start: an all-day event runs to the
147
+ * end of that day, a timed event runs an hour (rolling into the next day
148
+ * when the start is late in the evening).
149
+ */
150
+ export function defaultEndParts(start, allDay) {
151
+ if (allDay) {
152
+ return { ...start, hour: 23, minute: 59 };
153
+ }
154
+ const end = new Date(start.year, start.month - 1, start.day, start.hour + 1, start.minute, 0, 0);
155
+ return {
156
+ year: end.getFullYear(),
157
+ month: end.getMonth() + 1,
158
+ day: end.getDate(),
159
+ hour: end.getHours(),
160
+ minute: end.getMinutes()
161
+ };
162
+ }
163
+
164
+ function findEventHandler() {
165
+ return `on atmFindEvent(theUid)
166
+ tell application "Calendar"
167
+ repeat with cal in calendars
168
+ try
169
+ set hits to (every event of cal whose uid is theUid)
170
+ if (count of hits) > 0 then return item 1 of hits
171
+ end try
172
+ end repeat
173
+ end tell
174
+ error "EVENT_NOT_FOUND"
175
+ end atmFindEvent`;
176
+ }
177
+
178
+ function alarmLines(minutes, eventVar) {
179
+ return minutes
180
+ .map((m) => ` tell ${eventVar}
181
+ make new display alarm at end of display alarms with properties {trigger interval:-${asInteger(m, { min: 0, max: MAX_ALERT_MINUTES, field: "alert" })}}
182
+ end tell`)
183
+ .join("\n");
184
+ }
185
+
186
+ function failure(action, summary, result, secrets = []) {
187
+ if (result.kind === "tcc" || result.kind === "timeout") {
188
+ return `${action} failed — attempted to ${summary}. ${CALENDAR_TCC_GUIDANCE}`;
189
+ }
190
+ const raw = String(result.error || "");
191
+ if (raw.includes("CALENDAR_NOT_FOUND")) {
192
+ return `${action} failed — attempted to ${summary}. That calendar does not exist; call calendar_list_calendars first.`;
193
+ }
194
+ if (result.kind === "not_found" || raw.includes("EVENT_NOT_FOUND")) {
195
+ return `${action} failed — attempted to ${summary}. No event with that id was found; use the Event ID from calendar_date.`;
196
+ }
197
+ if (result.kind === "attribution") {
198
+ return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
199
+ }
200
+ if (result.kind === "app_unavailable") {
201
+ return `${action} failed — attempted to ${summary}. Calendar.app could not be reached on this host.`;
202
+ }
203
+ return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
204
+ }
205
+
206
+ /**
207
+ * calendar_remove must never hide the AppleEvent code behind TCC copy.
208
+ * Mini --apply: add/edit PASS then remove FAIL looked like a TCC deny
209
+ * because failure() dropped stderr when classify mapped ETIMEDOUT/-1712
210
+ * to tcc. Same write-bridge RPC as add/edit; not a different calendar
211
+ * account. Calendar.app has no `remove` / `move to trash` — only `delete`.
212
+ */
213
+ export function describeCalendarRemoveFailure(action, summary, appleResult, eventKitResult = null) {
214
+ const parts = [`AppleScript ${formatOsascriptDiagnostic(appleResult, "osascript")}`];
215
+ if (eventKitResult) {
216
+ parts.push(`EventKit ${formatOsascriptDiagnostic(eventKitResult, "osascript")}`);
217
+ }
218
+ const diagnostics = parts.join(" ");
219
+ const raw = String(appleResult.error || "");
220
+ const hardTcc = isHardTccDenial(raw);
221
+ const timedOut =
222
+ appleResult.kind === "timeout" ||
223
+ /delete_timeout|etimedout|-1712|appleevent timed out|timed out after/i.test(raw);
224
+
225
+ let head;
226
+ if (raw.includes("CALENDAR_NOT_FOUND")) {
227
+ head = `${action} failed — attempted to ${summary}. That calendar does not exist; call calendar_list_calendars first.`;
228
+ } else if (hardTcc) {
229
+ head = `${action} failed — attempted to ${summary}. ${CALENDAR_TCC_GUIDANCE}`;
230
+ } else if (timedOut) {
231
+ head = `${action} failed — attempted to ${summary}. Calendar.app delete timed out. That is not a TCC / Automation deny when calendar_add/calendar_edit succeed on this host — Calendar.app has no remove or move-to-trash; iCloud/CalDAV delete can hang or wait on a confirmation dialog.`;
232
+ } else if (appleResult.kind === "not_found" || raw.includes("EVENT_NOT_FOUND")) {
233
+ head = `${action} failed — attempted to ${summary}. No event with that id was found; use the Event ID from calendar_date.`;
234
+ } else if (appleResult.kind === "attribution") {
235
+ head = `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
236
+ } else if (appleResult.kind === "app_unavailable") {
237
+ head = `${action} failed — attempted to ${summary}. Calendar.app could not be reached on this host.`;
238
+ } else {
239
+ head = writeErrorMessage(action, summary, new Error(raw || "unknown error"));
240
+ }
241
+
242
+ return {
243
+ ok: false,
244
+ message: `${head} ${diagnostics}`,
245
+ suppressTccAdvice: !hardTcc,
246
+ diagnostics
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Non-recurring add must not fall through to Calendar.app: Mini writeOnly
252
+ * EventKit cannot delete AppleScript-created events, and Calendar.app
253
+ * delete hangs (ETIMEDOUT). Fail closed with the EventKit code.
254
+ */
255
+ export function describeCalendarAddFailure(action, summary, eventKitResult) {
256
+ const diagnostics = `EventKit ${formatOsascriptDiagnostic(eventKitResult)}`;
257
+ const raw = String(eventKitResult && eventKitResult.error ? eventKitResult.error : "");
258
+ const hardTcc = isHardTccDenial(raw) || /eventkit_denied/i.test(raw);
259
+ let head;
260
+ if (raw.includes("CALENDAR_NOT_FOUND")) {
261
+ head = `${action} failed — attempted to ${summary}. EventKit could not target that calendar (writeOnly often cannot list calendars; we use defaultCalendarForNewEvents). Call calendar_list_calendars first.`;
262
+ } else if (hardTcc) {
263
+ head = `${action} failed — attempted to ${summary}. ${CALENDAR_TCC_GUIDANCE}`;
264
+ } else {
265
+ head = `${action} failed — attempted to ${summary}. EventKit create failed; non-recurring add does not fall back to Calendar.app because writeOnly EventKit cannot see AppleScript-created events and Calendar.app delete hangs.`;
266
+ }
267
+ return {
268
+ ok: false,
269
+ message: `${head} ${diagnostics}`,
270
+ suppressTccAdvice: !hardTcc,
271
+ diagnostics
272
+ };
273
+ }
274
+
275
+ export function describeEventKitWriteFailure(action, summary, eventKitResult) {
276
+ const diagnostics = `EventKit ${formatOsascriptDiagnostic(eventKitResult)}`;
277
+ const raw = String(eventKitResult && eventKitResult.error ? eventKitResult.error : "");
278
+ const hardTcc = isHardTccDenial(raw) || /eventkit_denied/i.test(raw);
279
+ const head = raw.includes("EVENTKIT_NOT_FOUND")
280
+ ? `${action} failed — attempted to ${summary}. writeOnly EventKit cannot re-query by id; edit/remove need the in-memory EKEvent from the same EventKit session that created it. Calendar.app uid lookup was skipped to avoid an iCloud hang.`
281
+ : `${action} failed — attempted to ${summary}. EventKit write failed; Calendar.app was not used because eventkit_id was provided.`;
282
+ return {
283
+ ok: false,
284
+ message: `${head} ${diagnostics}`,
285
+ suppressTccAdvice: !hardTcc,
286
+ diagnostics
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Mini 74d304e: EventKit status=4 is writeOnly. That grant can create and
292
+ * delete events *this process* saved, but calendarItemsWithExternalIdentifier
293
+ * cannot see an event Calendar.app created via AppleScript. Align add→remove
294
+ * by creating through EventKit and deleting with the same identifiers
295
+ * (calendarItemExternalIdentifier and eventIdentifier).
296
+ */
297
+ export function localUnixSeconds(parts) {
298
+ return Math.floor(new Date(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 0, 0).getTime() / 1000);
299
+ }
300
+
301
+ export function parseEventKitAddOutput(output) {
302
+ const text = String(output || "").trim();
303
+ if (!text) return { eventId: null, eventKitId: null, calendar: null };
304
+ const [externalId, eventKitId, calendar, calendarItemId] = text.split("<<>>");
305
+ const ext = String(externalId || "").trim();
306
+ const local = String(eventKitId || "").trim();
307
+ const item = String(calendarItemId || "").trim();
308
+ return {
309
+ eventId: ext || local || item || null,
310
+ eventKitId: local || item || null,
311
+ calendar: String(calendar || "").trim() || null
312
+ };
313
+ }
314
+
315
+ export function parseEventKitCalendarList(output) {
316
+ return String(output || "")
317
+ .split("|||")
318
+ .map((entry) => entry.trim())
319
+ .filter((entry) => entry.length > 0)
320
+ .map((entry) => {
321
+ const parts = entry.split("<<>>");
322
+ if (parts.length < 3) return null;
323
+ const [name, writable, local, sourceType, source] = parts;
324
+ return {
325
+ name: name || "",
326
+ writable: writable !== "no",
327
+ local: local === "yes",
328
+ sourceType: sourceType === undefined || sourceType === "" ? null : Number(sourceType),
329
+ source: source || ""
330
+ };
331
+ })
332
+ .filter(Boolean);
333
+ }
334
+
335
+ /**
336
+ * JXA prints NSString as `[id NSTaggedPointerString]` if you `String(title)`.
337
+ * Mini cd74071: default=[id NSTaggedPointerString] so title match failed
338
+ * even though eventKitCalendars=1. Always ObjC.unwrap / .js.
339
+ */
340
+ export const EVENTKIT_JXA_HELPERS = `function jsString(value) {
341
+ if (value === undefined || value === null) return "";
342
+ if (typeof value === "string") {
343
+ if (value.indexOf("[id ") === 0 || value === "[object Object]") return "";
344
+ return value;
345
+ }
346
+ try {
347
+ if (typeof value.js === "string") return value.js;
348
+ } catch (e) {}
349
+ try {
350
+ var unwrapped = ObjC.unwrap(value);
351
+ if (typeof unwrapped === "string") return unwrapped;
352
+ } catch (e) {}
353
+ return "";
354
+ }
355
+ function titleOf(c) {
356
+ try { return c ? jsString(c.title) : ""; } catch (e) { return ""; }
357
+ }
358
+ function identifierOf(c) {
359
+ try { return c ? jsString(c.calendarIdentifier) : ""; } catch (e) { return ""; }
360
+ }
361
+ function asEvent(item) {
362
+ if (item === undefined || item === null) return false;
363
+ try { if (item.isKindOfClass && item.isKindOfClass($.EKEvent)) return true; } catch (e) {}
364
+ try { if (jsString(item.eventIdentifier)) return true; } catch (e) {}
365
+ try { if (jsString(item.calendarItemExternalIdentifier)) return true; } catch (e) {}
366
+ try { if (jsString(item.calendarItemIdentifier)) return true; } catch (e) {}
367
+ return false;
368
+ }
369
+ function nsId(id) {
370
+ return $.NSString.stringWithString(String(id));
371
+ }
372
+ function firstMatching(items) {
373
+ if (!items) return null;
374
+ var n = 0;
375
+ try { n = Number(items.count); } catch (e) { n = 0; }
376
+ for (var j = 0; j < n; j++) {
377
+ var hit = null;
378
+ try { hit = items.objectAtIndex(j); } catch (e) {}
379
+ if (asEvent(hit)) return hit;
380
+ }
381
+ return null;
382
+ }
383
+ function lookupOne(store, raw) {
384
+ if (!raw) return null;
385
+ var bridged = nsId(raw);
386
+ var candidates = [bridged, raw];
387
+ for (var k = 0; k < candidates.length; k++) {
388
+ var id = candidates[k];
389
+ var ev = null;
390
+ try { ev = store.eventWithIdentifier(id); } catch (e) {}
391
+ if (!asEvent(ev)) {
392
+ try { ev = store.eventWithIdentifier_(id); } catch (e) {}
393
+ }
394
+ if (asEvent(ev)) return ev;
395
+ try {
396
+ var item = store.calendarItemWithIdentifier(id);
397
+ if (asEvent(item)) return item;
398
+ } catch (e) {}
399
+ try {
400
+ var item2 = store.calendarItemWithIdentifier_(id);
401
+ if (asEvent(item2)) return item2;
402
+ } catch (e) {}
403
+ try {
404
+ var found = firstMatching(store.calendarItemsWithExternalIdentifier(id));
405
+ if (found) return found;
406
+ } catch (e) {}
407
+ try {
408
+ var found2 = firstMatching(store.calendarItemsWithExternalIdentifier_(id));
409
+ if (found2) return found2;
410
+ } catch (e) {}
411
+ }
412
+ return null;
413
+ }
414
+ function findEventByIds(store, ids) {
415
+ try { store.refreshSourcesIfNecessary(); } catch (e) {}
416
+ for (var i = 0; i < ids.length; i++) {
417
+ var found = lookupOne(store, ids[i]);
418
+ if (found) return found;
419
+ }
420
+ return null;
421
+ }
422
+ `;
423
+
424
+ /**
425
+ * Mini ed6d834: eventkit_id is `calendarUUID:eventUUID`. Look up the
426
+ * compound id and each half so eventWithIdentifier / externalIdentifier
427
+ * both get a usable string.
428
+ */
429
+ export function eventKitLookupIds(eventId, eventKitId) {
430
+ const out = [];
431
+ const seen = new Set();
432
+ const add = (value) => {
433
+ if (typeof value !== "string") return;
434
+ const trimmed = value.trim();
435
+ if (!trimmed || seen.has(trimmed)) return;
436
+ seen.add(trimmed);
437
+ out.push(trimmed);
438
+ };
439
+ add(eventKitId);
440
+ add(eventId);
441
+ for (const value of [...out]) {
442
+ if (value.includes(":")) {
443
+ for (const part of value.split(":")) add(part);
444
+ }
445
+ if (value.includes("/")) add(value.split("/")[0]);
446
+ }
447
+ return out;
448
+ }
449
+
450
+ function eventKitSessionRequest(cmd) {
451
+ const session = getEventKitSession() || ensureEventKitSession({ helpers: EVENTKIT_JXA_HELPERS });
452
+ if (!session) return null;
453
+ try {
454
+ return session.request(cmd);
455
+ } catch (e) {
456
+ try { session.close(); } catch { /* ignore */ }
457
+ setEventKitSession(null);
458
+ return { ok: false, error: e && e.message ? e.message : String(e), sessionDead: true };
459
+ }
460
+ }
461
+
462
+ export function mergeCalendarSources(appleCalendars, eventKitCalendars) {
463
+ if (!eventKitCalendars || eventKitCalendars.length === 0) return appleCalendars || [];
464
+ const byName = new Map(eventKitCalendars.map((c) => [c.name, c]));
465
+ return (appleCalendars || []).map((c) => {
466
+ const ek = byName.get(c.name);
467
+ return {
468
+ ...c,
469
+ local: ek ? ek.local : false,
470
+ sourceType: ek ? ek.sourceType : null,
471
+ source: ek ? ek.source : null
472
+ };
473
+ });
474
+ }
475
+
476
+ export function buildEventKitListCalendarsScript() {
477
+ return `ObjC.import("EventKit");
478
+ ObjC.import("Foundation");
479
+ ${EVENTKIT_JXA_HELPERS}
480
+ var store = $.EKEventStore.alloc.init;
481
+ var rows = [];
482
+ var seen = {};
483
+ function addRow(c) {
484
+ if (!c) return;
485
+ var name = titleOf(c);
486
+ if (!name || seen[name]) return;
487
+ seen[name] = true;
488
+ var src = null;
489
+ try { src = c.source; } catch (e) {}
490
+ var type = -1;
491
+ try { if (src) type = Number(src.sourceType); } catch (e) {}
492
+ var srcName = "";
493
+ try { if (src) srcName = jsString(src.title); } catch (e) {}
494
+ var writable = "yes";
495
+ try { if (c.allowsContentModifications === false) writable = "no"; } catch (e) {}
496
+ var local = type === 0 ? "yes" : "no";
497
+ rows.push(name + "<<>>" + writable + "<<>>" + local + "<<>>" + type + "<<>>" + srcName);
498
+ }
499
+ try {
500
+ var cals = store.calendarsForEntityType($.EKEntityTypeEvent);
501
+ for (var i = 0; i < cals.count; i++) addRow(cals.objectAtIndex(i));
502
+ } catch (e) {}
503
+ try { addRow(store.defaultCalendarForNewEvents); } catch (e) {}
504
+ rows.join("|||");`;
505
+ }
506
+
507
+ export function buildEventKitAddScript({ calendarName, title, start, end, allDay, location, notes, alerts }) {
508
+ const startSec = localUnixSeconds(start);
509
+ const endSec = localUnixSeconds(end);
510
+ return `ObjC.import("EventKit");
511
+ ObjC.import("Foundation");
512
+ ${EVENTKIT_JXA_HELPERS}
513
+ var calendarName = ${JSON.stringify(calendarName)};
514
+ var title = ${JSON.stringify(title)};
515
+ var startSec = ${asInteger(startSec, { field: "start" })};
516
+ var endSec = ${asInteger(endSec, { field: "end" })};
517
+ var allDay = ${allDay ? "true" : "false"};
518
+ var location = ${JSON.stringify(location || "")};
519
+ var notes = ${JSON.stringify(notes || "")};
520
+ var alerts = ${JSON.stringify(alerts || [])};
521
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
522
+ if (status === 1 || status === 2) {
523
+ throw new Error("EVENTKIT_DENIED status=" + status);
524
+ }
525
+ var store = $.EKEventStore.alloc.init;
526
+ var defaultCal = null;
527
+ try { defaultCal = store.defaultCalendarForNewEvents; } catch (e) {}
528
+ var cals = null;
529
+ var count = 0;
530
+ try {
531
+ cals = store.calendarsForEntityType($.EKEntityTypeEvent);
532
+ count = cals ? Number(cals.count) : 0;
533
+ } catch (e) {}
534
+ var target = null;
535
+ var writables = [];
536
+ var wanted = String(calendarName || "").toLowerCase();
537
+ function matchesWanted(c) {
538
+ var t = titleOf(c).toLowerCase();
539
+ var id = identifierOf(c);
540
+ return (t && t === wanted) || (id && (id === calendarName || id.toLowerCase() === wanted));
541
+ }
542
+ for (var i = 0; i < count; i++) {
543
+ var c = cals.objectAtIndex(i);
544
+ var writable = true;
545
+ try { writable = !!c.allowsContentModifications; } catch (e) {}
546
+ if (!writable) continue;
547
+ writables.push(c);
548
+ if (matchesWanted(c)) {
549
+ target = c;
550
+ break;
551
+ }
552
+ }
553
+ if (!target && defaultCal && matchesWanted(defaultCal)) {
554
+ target = defaultCal;
555
+ }
556
+ if (!target && writables.length === 1) {
557
+ target = writables[0];
558
+ }
559
+ if (!target && defaultCal) {
560
+ target = defaultCal;
561
+ }
562
+ if (!target) {
563
+ throw new Error("CALENDAR_NOT_FOUND status=" + status + " eventKitCalendars=" + count + " default=" + titleOf(defaultCal) + " defaultId=" + identifierOf(defaultCal));
564
+ }
565
+ var event = null;
566
+ try { event = $.EKEvent.eventWithEventStore(store); } catch (e) {}
567
+ if (!event) {
568
+ try { event = $.EKEvent.alloc.initWithEventStore(store); } catch (e2) {}
569
+ }
570
+ if (!event) throw new Error("EVENTKIT_NO_EVENT status=" + status);
571
+ event.title = title;
572
+ event.startDate = $.NSDate.dateWithTimeIntervalSince1970(startSec);
573
+ event.endDate = $.NSDate.dateWithTimeIntervalSince1970(endSec);
574
+ event.allDay = allDay;
575
+ try { event.calendar = target; } catch (e) {}
576
+ try { if (event.setCalendar) event.setCalendar(target); } catch (e) {}
577
+ if (location) event.location = location;
578
+ if (notes) event.notes = notes;
579
+ if (alerts && alerts.length) {
580
+ var alarms = $.NSMutableArray.array;
581
+ for (var a = 0; a < alerts.length; a++) {
582
+ alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
583
+ }
584
+ event.alarms = alarms;
585
+ }
586
+ var err = Ref();
587
+ var ok = false;
588
+ try {
589
+ ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
590
+ } catch (e) {
591
+ throw new Error("EVENTKIT_SAVE_FAILED: " + String(e));
592
+ }
593
+ if (!ok) {
594
+ var desc = "unknown";
595
+ try { desc = String(err[0]); } catch (e) {}
596
+ throw new Error("EVENTKIT_SAVE_FAILED: " + desc + " status=" + status);
597
+ }
598
+ var externalId = "";
599
+ var localId = "";
600
+ var itemId = "";
601
+ try { externalId = jsString(event.calendarItemExternalIdentifier); } catch (e) {}
602
+ try { localId = jsString(event.eventIdentifier); } catch (e) {}
603
+ try { itemId = jsString(event.calendarItemIdentifier); } catch (e) {}
604
+ if (!externalId && !localId && !itemId) throw new Error("EVENTKIT_NO_ID status=" + status);
605
+ externalId + "<<>>" + localId + "<<>>" + titleOf(target) + "<<>>" + itemId;`;
606
+ }
607
+
608
+ export function buildEventKitRemoveScript(eventId, { eventKitId = null } = {}) {
609
+ const ids = eventKitLookupIds(eventId, eventKitId);
610
+ return `ObjC.import("EventKit");
611
+ ObjC.import("Foundation");
612
+ ${EVENTKIT_JXA_HELPERS}
613
+ var ids = ${JSON.stringify(ids)};
614
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
615
+ if (status === 1 || status === 2) {
616
+ throw new Error("EVENTKIT_DENIED status=" + status);
617
+ }
618
+ var store = $.EKEventStore.alloc.init;
619
+ var found = findEventByIds(store, ids);
620
+ if (!found) {
621
+ throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " tried=" + ids.join(","));
622
+ }
623
+ var err = Ref();
624
+ var ok = store.removeEventSpanCommitError(found, $.EKSpanThisEvent, true, err);
625
+ if (!ok) {
626
+ var desc = "unknown";
627
+ try { desc = String(err[0]); } catch (e) {}
628
+ throw new Error("EVENTKIT_REMOVE_FAILED: " + desc);
629
+ }
630
+ 1;`;
631
+ }
632
+
633
+ /**
634
+ * List calendars so a caller can pick a target instead of defaulting.
635
+ */
636
+ export function calendarListCalendars() {
637
+ const action = "calendar_list_calendars";
638
+ const script = `set outputList to {}
639
+ tell application "Calendar"
640
+ repeat with cal in calendars
641
+ try
642
+ set calName to name of cal
643
+ set calWritable to "yes"
644
+ try
645
+ if writable of cal is false then set calWritable to "no"
646
+ end try
647
+ set end of outputList to calName & "<<>>" & calWritable
648
+ end try
649
+ end repeat
650
+ end tell
651
+ set AppleScript's text item delimiters to "|||"
652
+ return outputList as string`;
653
+
654
+ const result = runAppleScript(script, { timeout: 30000, appName: "Calendar" });
655
+ if (!result.ok) {
656
+ return { ok: false, message: failure(action, "list calendars", result) };
657
+ }
658
+
659
+ let calendars = result.output
660
+ .split("|||")
661
+ .map((entry) => entry.trim())
662
+ .filter((entry) => entry.length > 0)
663
+ .map((entry) => {
664
+ const [name, writable] = entry.split("<<>>");
665
+ return { name: name || "", writable: writable !== "no" };
666
+ });
667
+
668
+ const ekList = runAppleScript(buildEventKitListCalendarsScript(), {
669
+ timeout: 15000,
670
+ appName: "Calendar",
671
+ language: "JavaScript"
672
+ });
673
+ if (ekList.ok) {
674
+ calendars = mergeCalendarSources(calendars, parseEventKitCalendarList(ekList.output));
675
+ }
676
+
677
+ if (calendars.length === 0) {
678
+ return { ok: true, message: "No calendars found in Calendar.app." };
679
+ }
680
+
681
+ const lines = calendars.map((c) => {
682
+ const tags = [];
683
+ if (!c.writable) tags.push("read-only");
684
+ if (c.local) tags.push("On My Mac");
685
+ else if (c.source) tags.push(c.source);
686
+ return `• ${c.name}${tags.length ? ` (${tags.join(", ")})` : ""}`;
687
+ });
688
+ return {
689
+ ok: true,
690
+ message: `Calendars (${calendars.length}):\n${lines.join("\n")}\n\nPass one of these names as calendar_name when creating events.`,
691
+ calendars
692
+ };
693
+ }
694
+
695
+ export function buildAddEventScript({ calendarName, title, start, end, allDay, location, notes, rule, alerts }) {
696
+ const props = [
697
+ `summary:${asString(title)}`,
698
+ `start date:startDate`,
699
+ `end date:endDate`,
700
+ `allday event:${allDay ? "true" : "false"}`
701
+ ];
702
+ if (location) props.push(`location:${asString(location)}`);
703
+ if (notes) props.push(`description:${asString(notes)}`);
704
+
705
+ return `${DATE_HANDLER}
706
+
707
+ set startDate to ${dateCall(start)}
708
+ set endDate to ${dateCall(end)}
709
+ tell application "Calendar"
710
+ set targetCal to missing value
711
+ repeat with cal in calendars
712
+ try
713
+ if (name of cal) is ${asString(calendarName)} then
714
+ set targetCal to cal
715
+ exit repeat
716
+ end if
717
+ end try
718
+ end repeat
719
+ if targetCal is missing value then error "CALENDAR_NOT_FOUND"
720
+ tell targetCal
721
+ set newEvent to make new event with properties {${props.join(", ")}}
722
+ end tell
723
+ ${rule ? ` set recurrence of newEvent to ${asString(rule)}\n` : ""}${alerts.length ? `${alarmLines(alerts, "newEvent")}\n` : ""} set newUid to uid of newEvent
724
+ end tell
725
+ return newUid`;
726
+ }
727
+
728
+ export function calendarAdd(args = {}) {
729
+ const action = "calendar_add";
730
+
731
+ const calendarName = validateCalendarName(args.calendar_name);
732
+ if (!calendarName) {
733
+ return { ok: false, message: `${action} refused: calendar_name is required and must match a calendar from calendar_list_calendars.` };
734
+ }
735
+
736
+ const title = validateSubject(args.title, { required: true });
737
+ if (title.error) return { ok: false, message: `${action} refused: ${title.error.replace("subject", "title")}` };
738
+
739
+ const start = parseWriteDateTime(args.start, "start");
740
+ if (start.error) return { ok: false, message: `${action} refused: ${start.error}` };
741
+
742
+ const allDay = isFlagTrue(args.all_day) || (start.dateOnly && !args.end);
743
+ const end = args.end
744
+ ? parseWriteDateTime(args.end, "end")
745
+ : { parts: defaultEndParts(start.parts, allDay), error: null };
746
+ if (end.error) return { ok: false, message: `${action} refused: ${end.error}` };
747
+
748
+ const startMs = Date.UTC(start.parts.year, start.parts.month - 1, start.parts.day, start.parts.hour, start.parts.minute);
749
+ const endMs = Date.UTC(end.parts.year, end.parts.month - 1, end.parts.day, end.parts.hour, end.parts.minute);
750
+ if (endMs < startMs) {
751
+ return { ok: false, message: `${action} refused: end is before start` };
752
+ }
753
+
754
+ const location = validateSubject(args.location);
755
+ if (location.error) return { ok: false, message: `${action} refused: ${location.error.replace("subject", "location")}` };
756
+ const notes = validateBody(args.notes, { field: "notes" });
757
+ if (notes.error) return { ok: false, message: `${action} refused: ${notes.error}` };
758
+
759
+ const recurrence = buildRecurrenceRule(args);
760
+ if (recurrence.error) return { ok: false, message: `${action} refused: ${recurrence.error}` };
761
+ const alerts = validateAlerts(args.alerts_minutes_before);
762
+ if (alerts.error) return { ok: false, message: `${action} refused: ${alerts.error}` };
763
+
764
+ const summary = `create "${truncate(title.text, 120)}" on calendar "${calendarName}" from ${args.start} to ${args.end || "start + 1h"}` +
765
+ `${recurrence.rule ? ` repeating (${recurrence.rule})` : ""}` +
766
+ `${alerts.minutes.length ? ` with alerts ${alerts.minutes.join(", ")} min before` : ""}`;
767
+
768
+ const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
769
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
770
+
771
+ // Non-recurring creates MUST go through EventKit. Mini 8be6cd5: EventKit
772
+ // add failed (likely writeOnly cannot list calendars) and we silently
773
+ // created via Calendar.app — remove then EVENTKIT_NOT_FOUND status=4 and
774
+ // Calendar.app delete ETIMEDOUT. No AppleScript fallback here.
775
+ if (!recurrence.rule) {
776
+ const sessionReply = eventKitSessionRequest({
777
+ op: "create",
778
+ calendarName,
779
+ title: title.text,
780
+ startSec: localUnixSeconds(start.parts),
781
+ endSec: localUnixSeconds(end.parts),
782
+ allDay,
783
+ location: location.text || "",
784
+ notes: notes.text || "",
785
+ alerts: alerts.minutes
786
+ });
787
+ if (sessionReply && sessionReply.ok) {
788
+ const ids = parseEventKitAddOutput(sessionReply.output);
789
+ if (ids.eventId) {
790
+ return {
791
+ ok: true,
792
+ message: writeSuccessMessage(action, "event created", {
793
+ event_id: ids.eventId,
794
+ eventkit_id: ids.eventKitId || ids.eventId,
795
+ calendar: ids.calendar || calendarName,
796
+ via: "EventKit",
797
+ title: truncate(title.text, 150),
798
+ start: args.start,
799
+ alerts: alerts.minutes.length ? alerts.minutes.join(", ") : undefined
800
+ })
801
+ };
802
+ }
803
+ }
804
+ if (sessionReply && !sessionReply.ok && !sessionReply.sessionDead) {
805
+ return describeCalendarAddFailure(action, summary, {
806
+ ok: false,
807
+ error: sessionReply.error || "EVENTKIT_SESSION_CREATE_FAILED",
808
+ kind: "unknown"
809
+ });
810
+ }
811
+
812
+ // One-shot: return identifiers from the saved EKEvent. Do not re-query —
813
+ // writeOnly status=4 cannot eventWithIdentifier after save (Mini 2cdf44d).
814
+ let ek;
815
+ try {
816
+ ek = runAppleScript(
817
+ buildEventKitAddScript({
818
+ calendarName,
819
+ title: title.text,
820
+ start: start.parts,
821
+ end: end.parts,
822
+ allDay,
823
+ location: location.text,
824
+ notes: notes.text,
825
+ alerts: alerts.minutes
826
+ }),
827
+ { timeout: 30000, appName: "Calendar", language: "JavaScript" }
828
+ );
829
+ } catch (e) {
830
+ ek = { ok: false, error: e && e.message ? e.message : String(e), kind: "unknown" };
831
+ }
832
+ if (ek.ok) {
833
+ const ids = parseEventKitAddOutput(ek.output);
834
+ if (ids.eventId) {
835
+ return {
836
+ ok: true,
837
+ message: writeSuccessMessage(action, "event created", {
838
+ event_id: ids.eventId,
839
+ eventkit_id: ids.eventKitId || ids.eventId,
840
+ calendar: ids.calendar || calendarName,
841
+ via: "EventKit",
842
+ title: truncate(title.text, 150),
843
+ start: args.start,
844
+ alerts: alerts.minutes.length ? alerts.minutes.join(", ") : undefined
845
+ })
846
+ };
847
+ }
848
+ return describeCalendarAddFailure(
849
+ action,
850
+ summary,
851
+ { ok: false, error: `EVENTKIT_NO_ID output=${truncate(ek.output, 120)}`, kind: "unknown" }
852
+ );
853
+ }
854
+ return describeCalendarAddFailure(action, summary, ek);
855
+ }
856
+
857
+ let script;
858
+ try {
859
+ script = buildAddEventScript({
860
+ calendarName,
861
+ title: title.text,
862
+ start: start.parts,
863
+ end: end.parts,
864
+ allDay,
865
+ location: location.text,
866
+ notes: notes.text,
867
+ rule: recurrence.rule,
868
+ alerts: alerts.minutes
869
+ });
870
+ } catch (e) {
871
+ return { ok: false, message: `${action} refused: ${e.message}` };
872
+ }
873
+
874
+ const result = runAppleScript(script, { timeout: 60000, appName: "Calendar" });
875
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, [notes.text]) };
876
+
877
+ return {
878
+ ok: true,
879
+ message: writeSuccessMessage(action, "event created", {
880
+ event_id: result.output,
881
+ calendar: calendarName,
882
+ title: truncate(title.text, 150),
883
+ start: args.start,
884
+ recurrence: recurrence.rule || undefined,
885
+ alerts: alerts.minutes.length ? alerts.minutes.join(", ") : undefined
886
+ })
887
+ };
888
+ }
889
+
890
+ export function buildEventKitEditScript({
891
+ eventId,
892
+ eventKitId = null,
893
+ updates = {},
894
+ start = null,
895
+ end = null,
896
+ alerts = [],
897
+ clearAlerts = false
898
+ }) {
899
+ const ids = eventKitLookupIds(eventId, eventKitId);
900
+ const title = updates.summary ? JSON.stringify(updates.summary) : "null";
901
+ const location = updates.location !== undefined ? JSON.stringify(updates.location) : "null";
902
+ const notes = updates.description !== undefined ? JSON.stringify(updates.description) : "null";
903
+ const startSec = start ? localUnixSeconds(start) : null;
904
+ const endSec = end ? localUnixSeconds(end) : null;
905
+ return `ObjC.import("EventKit");
906
+ ObjC.import("Foundation");
907
+ ${EVENTKIT_JXA_HELPERS}
908
+ var ids = ${JSON.stringify(ids)};
909
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
910
+ if (status === 1 || status === 2) {
911
+ throw new Error("EVENTKIT_DENIED status=" + status);
912
+ }
913
+ var store = $.EKEventStore.alloc.init;
914
+ var event = findEventByIds(store, ids);
915
+ if (!event) {
916
+ throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " tried=" + ids.join(","));
917
+ }
918
+ var newTitle = ${title};
919
+ var newLocation = ${location};
920
+ var newNotes = ${notes};
921
+ if (newTitle !== null) event.title = newTitle;
922
+ if (newLocation !== null) event.location = newLocation;
923
+ if (newNotes !== null) event.notes = newNotes;
924
+ ${startSec !== null ? `event.startDate = $.NSDate.dateWithTimeIntervalSince1970(${asInteger(startSec, { field: "start" })});` : ""}
925
+ ${endSec !== null ? `event.endDate = $.NSDate.dateWithTimeIntervalSince1970(${asInteger(endSec, { field: "end" })});` : ""}
926
+ ${clearAlerts ? `try { event.alarms = $.NSMutableArray.array; } catch (e) {}` : ""}
927
+ ${alerts.length ? `var alarms = $.NSMutableArray.array;
928
+ for (var a = 0; a < ${JSON.stringify(alerts)}.length; a++) {
929
+ alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(${JSON.stringify(alerts)}[a]) * 60));
930
+ }
931
+ event.alarms = alarms;` : ""}
932
+ var err = Ref();
933
+ var ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
934
+ if (!ok) {
935
+ var desc = "unknown";
936
+ try { desc = String(err[0]); } catch (e) {}
937
+ throw new Error("EVENTKIT_SAVE_FAILED: " + desc);
938
+ }
939
+ jsString(event.eventIdentifier) || jsString(event.calendarItemExternalIdentifier) || "1";`;
940
+ }
941
+
942
+ export function buildEditEventScript({ eventId, updates, start, end, rule, alerts, clearAlerts }) {
943
+ const lines = [];
944
+ for (const [prop, value] of Object.entries(updates)) {
945
+ lines.push(` set ${prop} of theEvent to ${asString(value)}`);
946
+ }
947
+ if (start) lines.push(` set start date of theEvent to ${dateCall(start)}`);
948
+ if (end) lines.push(` set end date of theEvent to ${dateCall(end)}`);
949
+ if (rule) lines.push(` set recurrence of theEvent to ${asString(rule)}`);
950
+ if (clearAlerts) {
951
+ lines.push(` try
952
+ delete every display alarm of theEvent
953
+ end try`);
954
+ }
955
+
956
+ return `${DATE_HANDLER}
957
+ ${findEventHandler()}
958
+
959
+ set theEvent to atmFindEvent(${asString(eventId)})
960
+ tell application "Calendar"
961
+ ${lines.join("\n")}
962
+ ${alerts.length ? `${alarmLines(alerts, "theEvent")}\n` : ""} set editedUid to uid of theEvent
963
+ end tell
964
+ return editedUid`;
965
+ }
966
+
967
+ export function calendarEdit(args = {}) {
968
+ const action = "calendar_edit";
969
+
970
+ const eventId = validateEventId(args.event_id);
971
+ if (!eventId) {
972
+ return { ok: false, message: `${action} refused: event_id is required (the "Event ID" from calendar_date or calendar_add). This tool will not guess which event you meant.` };
973
+ }
974
+
975
+ const eventKitId = args.eventkit_id === undefined || args.eventkit_id === null || args.eventkit_id === ""
976
+ ? null
977
+ : validateEventKitId(args.eventkit_id);
978
+ if (args.eventkit_id && !eventKitId) {
979
+ return { ok: false, message: `${action} refused: eventkit_id is not a valid EventKit eventIdentifier.` };
980
+ }
981
+
982
+ const updates = {};
983
+ const changed = [];
984
+
985
+ if (args.title !== undefined) {
986
+ const title = validateSubject(args.title, { required: true });
987
+ if (title.error) return { ok: false, message: `${action} refused: ${title.error.replace("subject", "title")}` };
988
+ updates.summary = title.text;
989
+ changed.push(`title "${truncate(title.text, 80)}"`);
990
+ }
991
+ if (args.location !== undefined) {
992
+ const location = validateSubject(args.location);
993
+ if (location.error) return { ok: false, message: `${action} refused: ${location.error.replace("subject", "location")}` };
994
+ updates.location = location.text;
995
+ changed.push("location");
996
+ }
997
+ if (args.notes !== undefined) {
998
+ const notes = validateBody(args.notes, { field: "notes" });
999
+ if (notes.error) return { ok: false, message: `${action} refused: ${notes.error}` };
1000
+ updates.description = notes.text;
1001
+ changed.push("notes");
1002
+ }
1003
+
1004
+ let start = null;
1005
+ if (args.start !== undefined) {
1006
+ const parsed = parseWriteDateTime(args.start, "start");
1007
+ if (parsed.error) return { ok: false, message: `${action} refused: ${parsed.error}` };
1008
+ start = parsed.parts;
1009
+ changed.push(`start ${args.start}`);
1010
+ }
1011
+ let end = null;
1012
+ if (args.end !== undefined) {
1013
+ const parsed = parseWriteDateTime(args.end, "end");
1014
+ if (parsed.error) return { ok: false, message: `${action} refused: ${parsed.error}` };
1015
+ end = parsed.parts;
1016
+ changed.push(`end ${args.end}`);
1017
+ }
1018
+
1019
+ const recurrence = buildRecurrenceRule(args);
1020
+ if (recurrence.error) return { ok: false, message: `${action} refused: ${recurrence.error}` };
1021
+ if (recurrence.rule) changed.push(`recurrence (${recurrence.rule})`);
1022
+
1023
+ const alerts = validateAlerts(args.alerts_minutes_before);
1024
+ if (alerts.error) return { ok: false, message: `${action} refused: ${alerts.error}` };
1025
+ const clearAlerts = isFlagTrue(args.replace_alerts) || alerts.minutes.length > 0;
1026
+ if (alerts.minutes.length) changed.push(`alerts ${alerts.minutes.join(", ")} min before`);
1027
+
1028
+ if (changed.length === 0) {
1029
+ return { ok: false, message: `${action} refused: nothing to change. Pass at least one of title, start, end, location, notes, recurrence/frequency, alerts_minutes_before.` };
1030
+ }
1031
+
1032
+ const summary = `update event ${eventId}: ${changed.join(", ")}`;
1033
+ const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1034
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1035
+
1036
+ const preferEventKit = Boolean(eventKitId) || eventId.includes(":");
1037
+ if (preferEventKit && !recurrence.rule) {
1038
+ const sessionReply = eventKitSessionRequest({
1039
+ op: "update",
1040
+ ids: eventKitLookupIds(eventId, eventKitId),
1041
+ title: updates.summary,
1042
+ location: updates.location,
1043
+ notes: updates.description,
1044
+ startSec: start ? localUnixSeconds(start) : null,
1045
+ endSec: end ? localUnixSeconds(end) : null,
1046
+ alerts: alerts.minutes,
1047
+ clearAlerts
1048
+ });
1049
+ if (sessionReply && sessionReply.ok) {
1050
+ return {
1051
+ ok: true,
1052
+ message: writeSuccessMessage(action, "event updated", {
1053
+ event_id: eventId,
1054
+ eventkit_id: eventKitId || undefined,
1055
+ via: "EventKit",
1056
+ changed: changed.join(", ")
1057
+ })
1058
+ };
1059
+ }
1060
+ if (sessionReply && !sessionReply.ok && !sessionReply.sessionDead && eventKitId) {
1061
+ return describeEventKitWriteFailure(action, summary, {
1062
+ ok: false,
1063
+ error: sessionReply.error || "EVENTKIT_SESSION_UPDATE_FAILED",
1064
+ kind: "not_found"
1065
+ });
1066
+ }
1067
+
1068
+ const ek = runAppleScript(
1069
+ buildEventKitEditScript({
1070
+ eventId,
1071
+ eventKitId,
1072
+ updates,
1073
+ start,
1074
+ end,
1075
+ alerts: alerts.minutes,
1076
+ clearAlerts
1077
+ }),
1078
+ { timeout: 30000, appName: "Calendar", language: "JavaScript" }
1079
+ );
1080
+ if (ek.ok) {
1081
+ return {
1082
+ ok: true,
1083
+ message: writeSuccessMessage(action, "event updated", {
1084
+ event_id: eventId,
1085
+ eventkit_id: eventKitId || undefined,
1086
+ via: "EventKit",
1087
+ changed: changed.join(", ")
1088
+ })
1089
+ };
1090
+ }
1091
+ if (eventKitId) {
1092
+ return describeEventKitWriteFailure(action, summary, ek);
1093
+ }
1094
+ }
1095
+
1096
+ let script;
1097
+ try {
1098
+ script = buildEditEventScript({
1099
+ eventId,
1100
+ updates,
1101
+ start,
1102
+ end,
1103
+ rule: recurrence.rule,
1104
+ alerts: alerts.minutes,
1105
+ clearAlerts
1106
+ });
1107
+ } catch (e) {
1108
+ return { ok: false, message: `${action} refused: ${e.message}` };
1109
+ }
1110
+
1111
+ const result = runAppleScript(script, { timeout: 60000, appName: "Calendar" });
1112
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, [updates.description || ""]) };
1113
+
1114
+ return {
1115
+ ok: true,
1116
+ message: writeSuccessMessage(action, "event updated", { event_id: eventId, changed: changed.join(", ") })
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Delete via Calendar.app `delete` (the dictionary has no `remove` or
1122
+ * `move to trash`) using the AppleScript event `id` inside the owning
1123
+ * calendar. Do not `delete (every event whose uid …)` — that specifier
1124
+ * hangs on iCloud/CalDAV and Mini's 60s spawnSync timeout was then
1125
+ * classified as TCC. A detached `delete theEvent` after atmFindEvent
1126
+ * is also wrong (-1728). Lookup errors are not swallowed: a failed
1127
+ * delete must surface, then EventKit can run.
1128
+ */
1129
+ export function buildRemoveEventScript(eventId, { calendarName = null } = {}) {
1130
+ const uidLit = asString(eventId);
1131
+ const scoped = calendarName
1132
+ ? ` set targetCals to {}
1133
+ repeat with calRef in calendars
1134
+ try
1135
+ set cal to contents of calRef
1136
+ if (name of cal) is ${asString(calendarName)} then
1137
+ set end of targetCals to cal
1138
+ exit repeat
1139
+ end if
1140
+ end try
1141
+ end repeat
1142
+ if (count of targetCals) is 0 then error "CALENDAR_NOT_FOUND"`
1143
+ : ` set targetCals to calendars`;
1144
+
1145
+ return `tell application "Calendar"
1146
+ ${scoped}
1147
+ repeat with calRef in targetCals
1148
+ set cal to contents of calRef
1149
+ set hits to {}
1150
+ try
1151
+ set hits to (every event of cal whose uid is ${uidLit})
1152
+ end try
1153
+ if (count of hits) > 0 then
1154
+ set theEvent to item 1 of hits
1155
+ set removedTitle to summary of theEvent
1156
+ set evId to id of theEvent
1157
+ try
1158
+ with timeout of 20 seconds
1159
+ delete (event id evId of cal)
1160
+ end timeout
1161
+ on error err1 number n1
1162
+ if n1 is -1712 then error "DELETE_TIMEOUT: " & err1
1163
+ try
1164
+ with timeout of 20 seconds
1165
+ tell cal
1166
+ delete theEvent
1167
+ end tell
1168
+ end timeout
1169
+ on error err2 number n2
1170
+ if n2 is -1712 then error "DELETE_TIMEOUT: " & err2
1171
+ error "DELETE_FAILED: " & err2 number n2
1172
+ end try
1173
+ end try
1174
+ try
1175
+ reload calendars
1176
+ end try
1177
+ return removedTitle
1178
+ end if
1179
+ end repeat
1180
+ end tell
1181
+ error "EVENT_NOT_FOUND"`;
1182
+ }
1183
+
1184
+ export function calendarRemove(args = {}) {
1185
+ const action = "calendar_remove";
1186
+
1187
+ const eventId = validateEventId(args.event_id);
1188
+ if (!eventId) {
1189
+ return { ok: false, message: `${action} refused: event_id is required (the "Event ID" from calendar_date). Deletes never run on a guessed id.` };
1190
+ }
1191
+
1192
+ const calendarName = args.calendar_name === undefined || args.calendar_name === null || args.calendar_name === ""
1193
+ ? null
1194
+ : validateCalendarName(args.calendar_name);
1195
+ if (args.calendar_name && !calendarName) {
1196
+ return { ok: false, message: `${action} refused: calendar_name must match a calendar from calendar_list_calendars.` };
1197
+ }
1198
+
1199
+ const eventKitId = args.eventkit_id === undefined || args.eventkit_id === null || args.eventkit_id === ""
1200
+ ? null
1201
+ : validateEventKitId(args.eventkit_id);
1202
+ if (args.eventkit_id && !eventKitId) {
1203
+ return { ok: false, message: `${action} refused: eventkit_id is not a valid EventKit eventIdentifier.` };
1204
+ }
1205
+
1206
+ const summary = calendarName
1207
+ ? `delete calendar event ${eventId} from "${calendarName}"`
1208
+ : `delete calendar event ${eventId}`;
1209
+ const plan = planWrite({
1210
+ action,
1211
+ summary,
1212
+ destructive: true,
1213
+ dryRun: isFlagTrue(args.dry_run),
1214
+ confirm: isFlagTrue(args.confirm)
1215
+ });
1216
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1217
+
1218
+ // writeOnly cannot re-query; prefer the EventKit session that cached
1219
+ // the EKEvent from create. One-shot lookup is a full-access fallback.
1220
+ const sessionReply = eventKitSessionRequest({
1221
+ op: "remove",
1222
+ ids: eventKitLookupIds(eventId, eventKitId)
1223
+ });
1224
+ if (sessionReply && sessionReply.ok) {
1225
+ return {
1226
+ ok: true,
1227
+ message: writeSuccessMessage(action, "event deleted", {
1228
+ event_id: eventId,
1229
+ via: "EventKit",
1230
+ eventkit_id: eventKitId || undefined
1231
+ })
1232
+ };
1233
+ }
1234
+ if (sessionReply && !sessionReply.ok && !sessionReply.sessionDead && eventKitId) {
1235
+ return describeEventKitWriteFailure(action, summary, {
1236
+ ok: false,
1237
+ error: sessionReply.error || "EVENTKIT_SESSION_REMOVE_FAILED",
1238
+ kind: "not_found"
1239
+ });
1240
+ }
1241
+
1242
+ const eventKitResult = runAppleScript(buildEventKitRemoveScript(eventId, { eventKitId }), {
1243
+ timeout: 20000,
1244
+ appName: "Calendar",
1245
+ language: "JavaScript"
1246
+ });
1247
+ if (eventKitResult.ok) {
1248
+ return {
1249
+ ok: true,
1250
+ message: writeSuccessMessage(action, "event deleted", {
1251
+ event_id: eventId,
1252
+ via: "EventKit",
1253
+ eventkit_id: eventKitId || undefined
1254
+ })
1255
+ };
1256
+ }
1257
+
1258
+ if (eventKitId) {
1259
+ return describeEventKitWriteFailure(action, summary, eventKitResult);
1260
+ }
1261
+
1262
+ const result = runAppleScript(buildRemoveEventScript(eventId, { calendarName }), {
1263
+ timeout: 25000,
1264
+ appName: "Calendar"
1265
+ });
1266
+ if (result.ok) {
1267
+ return {
1268
+ ok: true,
1269
+ message: writeSuccessMessage(action, "event deleted", {
1270
+ event_id: eventId,
1271
+ title: truncate(result.output, 150) || undefined
1272
+ })
1273
+ };
1274
+ }
1275
+
1276
+ return describeCalendarRemoveFailure(action, summary, result, eventKitResult);
1277
+ }
1278
+
1279
+ export function buildRsvpScript({ eventId, status, attendeeEmail }) {
1280
+ const match = attendeeEmail
1281
+ ? ` if (email of att) is ${asString(attendeeEmail)} then set theAttendee to att`
1282
+ : ` try
1283
+ if (participation status of att) is needs action then set theAttendee to att
1284
+ end try`;
1285
+
1286
+ return `${findEventHandler()}
1287
+
1288
+ set theEvent to atmFindEvent(${asString(eventId)})
1289
+ tell application "Calendar"
1290
+ set theAttendee to missing value
1291
+ repeat with att in attendees of theEvent
1292
+ ${match}
1293
+ end repeat
1294
+ if theAttendee is missing value then error "ATTENDEE_NOT_FOUND"
1295
+ try
1296
+ set participation status of theAttendee to ${status}
1297
+ on error errText
1298
+ error "RSVP_NOT_SUPPORTED: " & errText
1299
+ end try
1300
+ set rsvpTitle to summary of theEvent
1301
+ end tell
1302
+ return rsvpTitle`;
1303
+ }
1304
+
1305
+ /**
1306
+ * RSVP to an invitation.
1307
+ *
1308
+ * Calendar.app exposes `participation status` on attendees but some macOS
1309
+ * versions refuse to write it from AppleScript. When that happens the tool
1310
+ * reports the refusal instead of silently doing nothing.
1311
+ */
1312
+ export function calendarRsvp(args = {}) {
1313
+ const action = "calendar_rsvp";
1314
+
1315
+ const eventId = validateEventId(args.event_id);
1316
+ if (!eventId) {
1317
+ return { ok: false, message: `${action} refused: event_id is required (the "Event ID" from calendar_date).` };
1318
+ }
1319
+
1320
+ const responseRaw = args.response === undefined ? "" : String(args.response).toLowerCase();
1321
+ if (!RSVP_RESPONSES.includes(responseRaw)) {
1322
+ return { ok: false, message: `${action} refused: response must be one of: ${RSVP_RESPONSES.join(", ")}` };
1323
+ }
1324
+
1325
+ let attendeeEmail = null;
1326
+ if (args.attendee_email) {
1327
+ const list = normalizeList(args.attendee_email);
1328
+ attendeeEmail = list[0] || null;
1329
+ if (attendeeEmail && !/^[^\s@]{1,64}@[^\s@]{1,190}$/.test(attendeeEmail)) {
1330
+ return { ok: false, message: `${action} refused: attendee_email is not a valid address` };
1331
+ }
1332
+ }
1333
+
1334
+ const summary = `RSVP ${responseRaw} to event ${eventId}`;
1335
+ const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1336
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1337
+
1338
+ const result = runAppleScript(
1339
+ buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
1340
+ { timeout: 60000, appName: "Calendar" }
1341
+ );
1342
+
1343
+ if (!result.ok) {
1344
+ const raw = String(result.error || "");
1345
+ if (raw.includes("ATTENDEE_NOT_FOUND")) {
1346
+ return {
1347
+ ok: false,
1348
+ message: `${action} failed — attempted to ${summary}. No matching attendee was found on that event; pass attendee_email for the invited address.`
1349
+ };
1350
+ }
1351
+ if (raw.includes("RSVP_NOT_SUPPORTED")) {
1352
+ return {
1353
+ ok: false,
1354
+ message: `${action} failed — attempted to ${summary}. Calendar.app refused to change the participation status on this macOS version; answer the invitation in Calendar directly.`
1355
+ };
1356
+ }
1357
+ return { ok: false, message: failure(action, summary, result) };
1358
+ }
1359
+
1360
+ return {
1361
+ ok: true,
1362
+ message: writeSuccessMessage(action, `RSVP ${responseRaw} recorded`, {
1363
+ event_id: eventId,
1364
+ title: truncate(result.output, 150) || undefined
1365
+ })
1366
+ };
1367
+ }