apple-tools-mcp 1.2.1 → 2.0.0
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/README.md +261 -7
- package/contacts.js +71 -19
- package/index.js +80 -4
- package/indexer.js +7 -2
- package/lib/appleScript.js +292 -0
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +3 -0
|
@@ -0,0 +1,594 @@
|
|
|
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
|
+
} from "./appleScript.js";
|
|
20
|
+
import {
|
|
21
|
+
planWrite,
|
|
22
|
+
validateEventId,
|
|
23
|
+
validateCalendarName,
|
|
24
|
+
validateBody,
|
|
25
|
+
validateSubject,
|
|
26
|
+
writeErrorMessage,
|
|
27
|
+
writeSuccessMessage,
|
|
28
|
+
isFlagTrue,
|
|
29
|
+
normalizeList,
|
|
30
|
+
truncate
|
|
31
|
+
} from "./writeGuards.js";
|
|
32
|
+
|
|
33
|
+
export const RECURRENCE_FREQUENCIES = ["daily", "weekly", "monthly", "yearly"];
|
|
34
|
+
export const RECURRENCE_DAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
|
35
|
+
export const RSVP_RESPONSES = ["accept", "decline", "tentative"];
|
|
36
|
+
|
|
37
|
+
const RSVP_STATUS = {
|
|
38
|
+
accept: "accepted",
|
|
39
|
+
decline: "declined",
|
|
40
|
+
tentative: "tentative"
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Alerts are minutes before the event start; capped at four weeks.
|
|
44
|
+
const MAX_ALERT_MINUTES = 40320;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build an RFC 5545 RRULE from structured arguments, or validate a raw rule.
|
|
48
|
+
*
|
|
49
|
+
* Supported patterns:
|
|
50
|
+
* FREQ=DAILY|WEEKLY|MONTHLY|YEARLY with optional INTERVAL, COUNT or UNTIL,
|
|
51
|
+
* and BYDAY (weekly).
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ rule: string|null, error: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
export function buildRecurrenceRule(options = {}) {
|
|
56
|
+
const { recurrence, frequency, interval, count, until, by_day: byDay } = options;
|
|
57
|
+
|
|
58
|
+
if (recurrence) {
|
|
59
|
+
if (typeof recurrence !== "string") return { rule: null, error: "recurrence must be a string" };
|
|
60
|
+
const raw = recurrence.trim().toUpperCase().replace(/^RRULE:/, "");
|
|
61
|
+
if (!/^FREQ=[A-Z0-9=;,:+-]{1,290}$/.test(raw)) {
|
|
62
|
+
return { rule: null, error: 'recurrence must be an RRULE starting with FREQ= (for example "FREQ=WEEKLY;INTERVAL=1;COUNT=10")' };
|
|
63
|
+
}
|
|
64
|
+
return { rule: raw, error: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!frequency) return { rule: null, error: null };
|
|
68
|
+
|
|
69
|
+
const freq = String(frequency).toLowerCase();
|
|
70
|
+
if (!RECURRENCE_FREQUENCIES.includes(freq)) {
|
|
71
|
+
return { rule: null, error: `frequency must be one of: ${RECURRENCE_FREQUENCIES.join(", ")}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parts = [`FREQ=${freq.toUpperCase()}`];
|
|
75
|
+
|
|
76
|
+
if (interval !== undefined && interval !== null && interval !== "") {
|
|
77
|
+
const n = Number(interval);
|
|
78
|
+
if (!Number.isInteger(n) || n < 1 || n > 366) {
|
|
79
|
+
return { rule: null, error: "interval must be an integer between 1 and 366" };
|
|
80
|
+
}
|
|
81
|
+
parts.push(`INTERVAL=${n}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (byDay !== undefined && byDay !== null && byDay !== "") {
|
|
85
|
+
const days = normalizeList(byDay).map((d) => d.toUpperCase());
|
|
86
|
+
const bad = days.filter((d) => !RECURRENCE_DAYS.includes(d));
|
|
87
|
+
if (bad.length > 0) {
|
|
88
|
+
return { rule: null, error: `by_day accepts ${RECURRENCE_DAYS.join(", ")}; got ${bad.join(", ")}` };
|
|
89
|
+
}
|
|
90
|
+
if (days.length > 0) parts.push(`BYDAY=${days.join(",")}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (count !== undefined && count !== null && count !== "") {
|
|
94
|
+
if (until) return { rule: null, error: "use either count or until, not both" };
|
|
95
|
+
const n = Number(count);
|
|
96
|
+
if (!Number.isInteger(n) || n < 1 || n > 1000) {
|
|
97
|
+
return { rule: null, error: "count must be an integer between 1 and 1000" };
|
|
98
|
+
}
|
|
99
|
+
parts.push(`COUNT=${n}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (until) {
|
|
103
|
+
const parsed = parseWriteDateTime(until, "until");
|
|
104
|
+
if (parsed.error) return { rule: null, error: parsed.error };
|
|
105
|
+
const p = parsed.parts;
|
|
106
|
+
const pad = (v) => String(v).padStart(2, "0");
|
|
107
|
+
// UNTIL is expressed in UTC per RFC 5545.
|
|
108
|
+
const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, 0));
|
|
109
|
+
parts.push(
|
|
110
|
+
`UNTIL=${utc.getUTCFullYear()}${pad(utc.getUTCMonth() + 1)}${pad(utc.getUTCDate())}T` +
|
|
111
|
+
`${pad(utc.getUTCHours())}${pad(utc.getUTCMinutes())}00Z`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { rule: parts.join(";"), error: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate the alerts list (minutes before start).
|
|
120
|
+
* @returns {{ minutes: number[], error: string|null }}
|
|
121
|
+
*/
|
|
122
|
+
export function validateAlerts(value) {
|
|
123
|
+
if (value === undefined || value === null || value === "") return { minutes: [], error: null };
|
|
124
|
+
const entries = normalizeList(value);
|
|
125
|
+
const minutes = [];
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const n = Number(entry);
|
|
128
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_ALERT_MINUTES) {
|
|
129
|
+
return { minutes: [], error: `alerts_minutes_before accepts whole minutes from 0 to ${MAX_ALERT_MINUTES}; got ${truncate(entry, 40)}` };
|
|
130
|
+
}
|
|
131
|
+
minutes.push(n);
|
|
132
|
+
}
|
|
133
|
+
if (minutes.length > 5) return { minutes: [], error: "at most 5 alerts per event" };
|
|
134
|
+
return { minutes, error: null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* End time when the caller gave only a start: an all-day event runs to the
|
|
139
|
+
* end of that day, a timed event runs an hour (rolling into the next day
|
|
140
|
+
* when the start is late in the evening).
|
|
141
|
+
*/
|
|
142
|
+
export function defaultEndParts(start, allDay) {
|
|
143
|
+
if (allDay) {
|
|
144
|
+
return { ...start, hour: 23, minute: 59 };
|
|
145
|
+
}
|
|
146
|
+
const end = new Date(start.year, start.month - 1, start.day, start.hour + 1, start.minute, 0, 0);
|
|
147
|
+
return {
|
|
148
|
+
year: end.getFullYear(),
|
|
149
|
+
month: end.getMonth() + 1,
|
|
150
|
+
day: end.getDate(),
|
|
151
|
+
hour: end.getHours(),
|
|
152
|
+
minute: end.getMinutes()
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function findEventHandler() {
|
|
157
|
+
return `on atmFindEvent(theUid)
|
|
158
|
+
tell application "Calendar"
|
|
159
|
+
repeat with cal in calendars
|
|
160
|
+
try
|
|
161
|
+
set hits to (every event of cal whose uid is theUid)
|
|
162
|
+
if (count of hits) > 0 then return item 1 of hits
|
|
163
|
+
end try
|
|
164
|
+
end repeat
|
|
165
|
+
end tell
|
|
166
|
+
error "EVENT_NOT_FOUND"
|
|
167
|
+
end atmFindEvent`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function alarmLines(minutes, eventVar) {
|
|
171
|
+
return minutes
|
|
172
|
+
.map((m) => ` tell ${eventVar}
|
|
173
|
+
make new display alarm at end of display alarms with properties {trigger interval:-${asInteger(m, { min: 0, max: MAX_ALERT_MINUTES, field: "alert" })}}
|
|
174
|
+
end tell`)
|
|
175
|
+
.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function failure(action, summary, result, secrets = []) {
|
|
179
|
+
if (result.kind === "tcc") {
|
|
180
|
+
return `${action} failed — attempted to ${summary}. ${CALENDAR_TCC_GUIDANCE}`;
|
|
181
|
+
}
|
|
182
|
+
const raw = String(result.error || "");
|
|
183
|
+
if (raw.includes("CALENDAR_NOT_FOUND")) {
|
|
184
|
+
return `${action} failed — attempted to ${summary}. That calendar does not exist; call calendar_list_calendars first.`;
|
|
185
|
+
}
|
|
186
|
+
if (raw.includes("EVENT_NOT_FOUND")) {
|
|
187
|
+
return `${action} failed — attempted to ${summary}. No event with that id was found; use the Event ID from calendar_date.`;
|
|
188
|
+
}
|
|
189
|
+
if (result.kind === "attribution") {
|
|
190
|
+
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
191
|
+
}
|
|
192
|
+
if (result.kind === "app_unavailable") {
|
|
193
|
+
return `${action} failed — attempted to ${summary}. Calendar.app could not be reached on this host.`;
|
|
194
|
+
}
|
|
195
|
+
return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List calendars so a caller can pick a target instead of defaulting.
|
|
200
|
+
*/
|
|
201
|
+
export function calendarListCalendars() {
|
|
202
|
+
const action = "calendar_list_calendars";
|
|
203
|
+
const script = `set outputList to {}
|
|
204
|
+
tell application "Calendar"
|
|
205
|
+
repeat with cal in calendars
|
|
206
|
+
try
|
|
207
|
+
set calName to name of cal
|
|
208
|
+
set calWritable to "yes"
|
|
209
|
+
try
|
|
210
|
+
if writable of cal is false then set calWritable to "no"
|
|
211
|
+
end try
|
|
212
|
+
set end of outputList to calName & "<<>>" & calWritable
|
|
213
|
+
end try
|
|
214
|
+
end repeat
|
|
215
|
+
end tell
|
|
216
|
+
set AppleScript's text item delimiters to "|||"
|
|
217
|
+
return outputList as string`;
|
|
218
|
+
|
|
219
|
+
const result = runAppleScript(script, { timeout: 30000, appName: "Calendar" });
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
return { ok: false, message: failure(action, "list calendars", result) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const calendars = result.output
|
|
225
|
+
.split("|||")
|
|
226
|
+
.map((entry) => entry.trim())
|
|
227
|
+
.filter((entry) => entry.length > 0)
|
|
228
|
+
.map((entry) => {
|
|
229
|
+
const [name, writable] = entry.split("<<>>");
|
|
230
|
+
return { name: name || "", writable: writable !== "no" };
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (calendars.length === 0) {
|
|
234
|
+
return { ok: true, message: "No calendars found in Calendar.app." };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const lines = calendars.map((c) => `• ${c.name}${c.writable ? "" : " (read-only)"}`);
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
message: `Calendars (${calendars.length}):\n${lines.join("\n")}\n\nPass one of these names as calendar_name when creating events.`,
|
|
241
|
+
calendars
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function buildAddEventScript({ calendarName, title, start, end, allDay, location, notes, rule, alerts }) {
|
|
246
|
+
const props = [
|
|
247
|
+
`summary:${asString(title)}`,
|
|
248
|
+
`start date:startDate`,
|
|
249
|
+
`end date:endDate`,
|
|
250
|
+
`allday event:${allDay ? "true" : "false"}`
|
|
251
|
+
];
|
|
252
|
+
if (location) props.push(`location:${asString(location)}`);
|
|
253
|
+
if (notes) props.push(`description:${asString(notes)}`);
|
|
254
|
+
|
|
255
|
+
return `${DATE_HANDLER}
|
|
256
|
+
|
|
257
|
+
set startDate to ${dateCall(start)}
|
|
258
|
+
set endDate to ${dateCall(end)}
|
|
259
|
+
tell application "Calendar"
|
|
260
|
+
set targetCal to missing value
|
|
261
|
+
repeat with cal in calendars
|
|
262
|
+
try
|
|
263
|
+
if (name of cal) is ${asString(calendarName)} then
|
|
264
|
+
set targetCal to cal
|
|
265
|
+
exit repeat
|
|
266
|
+
end if
|
|
267
|
+
end try
|
|
268
|
+
end repeat
|
|
269
|
+
if targetCal is missing value then error "CALENDAR_NOT_FOUND"
|
|
270
|
+
tell targetCal
|
|
271
|
+
set newEvent to make new event with properties {${props.join(", ")}}
|
|
272
|
+
end tell
|
|
273
|
+
${rule ? ` set recurrence of newEvent to ${asString(rule)}\n` : ""}${alerts.length ? `${alarmLines(alerts, "newEvent")}\n` : ""} set newUid to uid of newEvent
|
|
274
|
+
end tell
|
|
275
|
+
return newUid`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function calendarAdd(args = {}) {
|
|
279
|
+
const action = "calendar_add";
|
|
280
|
+
|
|
281
|
+
const calendarName = validateCalendarName(args.calendar_name);
|
|
282
|
+
if (!calendarName) {
|
|
283
|
+
return { ok: false, message: `${action} refused: calendar_name is required and must match a calendar from calendar_list_calendars.` };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const title = validateSubject(args.title, { required: true });
|
|
287
|
+
if (title.error) return { ok: false, message: `${action} refused: ${title.error.replace("subject", "title")}` };
|
|
288
|
+
|
|
289
|
+
const start = parseWriteDateTime(args.start, "start");
|
|
290
|
+
if (start.error) return { ok: false, message: `${action} refused: ${start.error}` };
|
|
291
|
+
|
|
292
|
+
const allDay = isFlagTrue(args.all_day) || (start.dateOnly && !args.end);
|
|
293
|
+
const end = args.end
|
|
294
|
+
? parseWriteDateTime(args.end, "end")
|
|
295
|
+
: { parts: defaultEndParts(start.parts, allDay), error: null };
|
|
296
|
+
if (end.error) return { ok: false, message: `${action} refused: ${end.error}` };
|
|
297
|
+
|
|
298
|
+
const startMs = Date.UTC(start.parts.year, start.parts.month - 1, start.parts.day, start.parts.hour, start.parts.minute);
|
|
299
|
+
const endMs = Date.UTC(end.parts.year, end.parts.month - 1, end.parts.day, end.parts.hour, end.parts.minute);
|
|
300
|
+
if (endMs < startMs) {
|
|
301
|
+
return { ok: false, message: `${action} refused: end is before start` };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const location = validateSubject(args.location);
|
|
305
|
+
if (location.error) return { ok: false, message: `${action} refused: ${location.error.replace("subject", "location")}` };
|
|
306
|
+
const notes = validateBody(args.notes, { field: "notes" });
|
|
307
|
+
if (notes.error) return { ok: false, message: `${action} refused: ${notes.error}` };
|
|
308
|
+
|
|
309
|
+
const recurrence = buildRecurrenceRule(args);
|
|
310
|
+
if (recurrence.error) return { ok: false, message: `${action} refused: ${recurrence.error}` };
|
|
311
|
+
const alerts = validateAlerts(args.alerts_minutes_before);
|
|
312
|
+
if (alerts.error) return { ok: false, message: `${action} refused: ${alerts.error}` };
|
|
313
|
+
|
|
314
|
+
const summary = `create "${truncate(title.text, 120)}" on calendar "${calendarName}" from ${args.start} to ${args.end || "start + 1h"}` +
|
|
315
|
+
`${recurrence.rule ? ` repeating (${recurrence.rule})` : ""}` +
|
|
316
|
+
`${alerts.minutes.length ? ` with alerts ${alerts.minutes.join(", ")} min before` : ""}`;
|
|
317
|
+
|
|
318
|
+
const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
|
|
319
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
320
|
+
|
|
321
|
+
let script;
|
|
322
|
+
try {
|
|
323
|
+
script = buildAddEventScript({
|
|
324
|
+
calendarName,
|
|
325
|
+
title: title.text,
|
|
326
|
+
start: start.parts,
|
|
327
|
+
end: end.parts,
|
|
328
|
+
allDay,
|
|
329
|
+
location: location.text,
|
|
330
|
+
notes: notes.text,
|
|
331
|
+
rule: recurrence.rule,
|
|
332
|
+
alerts: alerts.minutes
|
|
333
|
+
});
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return { ok: false, message: `${action} refused: ${e.message}` };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = runAppleScript(script, { timeout: 60000, appName: "Calendar" });
|
|
339
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result, [notes.text]) };
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
message: writeSuccessMessage(action, "event created", {
|
|
344
|
+
event_id: result.output,
|
|
345
|
+
calendar: calendarName,
|
|
346
|
+
title: truncate(title.text, 150),
|
|
347
|
+
start: args.start,
|
|
348
|
+
recurrence: recurrence.rule || undefined,
|
|
349
|
+
alerts: alerts.minutes.length ? alerts.minutes.join(", ") : undefined
|
|
350
|
+
})
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function buildEditEventScript({ eventId, updates, start, end, rule, alerts, clearAlerts }) {
|
|
355
|
+
const lines = [];
|
|
356
|
+
for (const [prop, value] of Object.entries(updates)) {
|
|
357
|
+
lines.push(` set ${prop} of theEvent to ${asString(value)}`);
|
|
358
|
+
}
|
|
359
|
+
if (start) lines.push(` set start date of theEvent to ${dateCall(start)}`);
|
|
360
|
+
if (end) lines.push(` set end date of theEvent to ${dateCall(end)}`);
|
|
361
|
+
if (rule) lines.push(` set recurrence of theEvent to ${asString(rule)}`);
|
|
362
|
+
if (clearAlerts) {
|
|
363
|
+
lines.push(` try
|
|
364
|
+
delete every display alarm of theEvent
|
|
365
|
+
end try`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return `${DATE_HANDLER}
|
|
369
|
+
${findEventHandler()}
|
|
370
|
+
|
|
371
|
+
set theEvent to atmFindEvent(${asString(eventId)})
|
|
372
|
+
tell application "Calendar"
|
|
373
|
+
${lines.join("\n")}
|
|
374
|
+
${alerts.length ? `${alarmLines(alerts, "theEvent")}\n` : ""} set editedUid to uid of theEvent
|
|
375
|
+
end tell
|
|
376
|
+
return editedUid`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function calendarEdit(args = {}) {
|
|
380
|
+
const action = "calendar_edit";
|
|
381
|
+
|
|
382
|
+
const eventId = validateEventId(args.event_id);
|
|
383
|
+
if (!eventId) {
|
|
384
|
+
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.` };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const updates = {};
|
|
388
|
+
const changed = [];
|
|
389
|
+
|
|
390
|
+
if (args.title !== undefined) {
|
|
391
|
+
const title = validateSubject(args.title, { required: true });
|
|
392
|
+
if (title.error) return { ok: false, message: `${action} refused: ${title.error.replace("subject", "title")}` };
|
|
393
|
+
updates.summary = title.text;
|
|
394
|
+
changed.push(`title "${truncate(title.text, 80)}"`);
|
|
395
|
+
}
|
|
396
|
+
if (args.location !== undefined) {
|
|
397
|
+
const location = validateSubject(args.location);
|
|
398
|
+
if (location.error) return { ok: false, message: `${action} refused: ${location.error.replace("subject", "location")}` };
|
|
399
|
+
updates.location = location.text;
|
|
400
|
+
changed.push("location");
|
|
401
|
+
}
|
|
402
|
+
if (args.notes !== undefined) {
|
|
403
|
+
const notes = validateBody(args.notes, { field: "notes" });
|
|
404
|
+
if (notes.error) return { ok: false, message: `${action} refused: ${notes.error}` };
|
|
405
|
+
updates.description = notes.text;
|
|
406
|
+
changed.push("notes");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let start = null;
|
|
410
|
+
if (args.start !== undefined) {
|
|
411
|
+
const parsed = parseWriteDateTime(args.start, "start");
|
|
412
|
+
if (parsed.error) return { ok: false, message: `${action} refused: ${parsed.error}` };
|
|
413
|
+
start = parsed.parts;
|
|
414
|
+
changed.push(`start ${args.start}`);
|
|
415
|
+
}
|
|
416
|
+
let end = null;
|
|
417
|
+
if (args.end !== undefined) {
|
|
418
|
+
const parsed = parseWriteDateTime(args.end, "end");
|
|
419
|
+
if (parsed.error) return { ok: false, message: `${action} refused: ${parsed.error}` };
|
|
420
|
+
end = parsed.parts;
|
|
421
|
+
changed.push(`end ${args.end}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const recurrence = buildRecurrenceRule(args);
|
|
425
|
+
if (recurrence.error) return { ok: false, message: `${action} refused: ${recurrence.error}` };
|
|
426
|
+
if (recurrence.rule) changed.push(`recurrence (${recurrence.rule})`);
|
|
427
|
+
|
|
428
|
+
const alerts = validateAlerts(args.alerts_minutes_before);
|
|
429
|
+
if (alerts.error) return { ok: false, message: `${action} refused: ${alerts.error}` };
|
|
430
|
+
const clearAlerts = isFlagTrue(args.replace_alerts) || alerts.minutes.length > 0;
|
|
431
|
+
if (alerts.minutes.length) changed.push(`alerts ${alerts.minutes.join(", ")} min before`);
|
|
432
|
+
|
|
433
|
+
if (changed.length === 0) {
|
|
434
|
+
return { ok: false, message: `${action} refused: nothing to change. Pass at least one of title, start, end, location, notes, recurrence/frequency, alerts_minutes_before.` };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const summary = `update event ${eventId}: ${changed.join(", ")}`;
|
|
438
|
+
const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
|
|
439
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
440
|
+
|
|
441
|
+
let script;
|
|
442
|
+
try {
|
|
443
|
+
script = buildEditEventScript({
|
|
444
|
+
eventId,
|
|
445
|
+
updates,
|
|
446
|
+
start,
|
|
447
|
+
end,
|
|
448
|
+
rule: recurrence.rule,
|
|
449
|
+
alerts: alerts.minutes,
|
|
450
|
+
clearAlerts
|
|
451
|
+
});
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return { ok: false, message: `${action} refused: ${e.message}` };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const result = runAppleScript(script, { timeout: 60000, appName: "Calendar" });
|
|
457
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result, [updates.description || ""]) };
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
message: writeSuccessMessage(action, "event updated", { event_id: eventId, changed: changed.join(", ") })
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function buildRemoveEventScript(eventId) {
|
|
466
|
+
return `${findEventHandler()}
|
|
467
|
+
|
|
468
|
+
set theEvent to atmFindEvent(${asString(eventId)})
|
|
469
|
+
tell application "Calendar"
|
|
470
|
+
set removedTitle to summary of theEvent
|
|
471
|
+
delete theEvent
|
|
472
|
+
end tell
|
|
473
|
+
return removedTitle`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function calendarRemove(args = {}) {
|
|
477
|
+
const action = "calendar_remove";
|
|
478
|
+
|
|
479
|
+
const eventId = validateEventId(args.event_id);
|
|
480
|
+
if (!eventId) {
|
|
481
|
+
return { ok: false, message: `${action} refused: event_id is required (the "Event ID" from calendar_date). Deletes never run on a guessed id.` };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const summary = `delete calendar event ${eventId}`;
|
|
485
|
+
const plan = planWrite({
|
|
486
|
+
action,
|
|
487
|
+
summary,
|
|
488
|
+
destructive: true,
|
|
489
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
490
|
+
confirm: isFlagTrue(args.confirm)
|
|
491
|
+
});
|
|
492
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
493
|
+
|
|
494
|
+
const result = runAppleScript(buildRemoveEventScript(eventId), { timeout: 60000, appName: "Calendar" });
|
|
495
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
message: writeSuccessMessage(action, "event deleted", {
|
|
500
|
+
event_id: eventId,
|
|
501
|
+
title: truncate(result.output, 150) || undefined
|
|
502
|
+
})
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function buildRsvpScript({ eventId, status, attendeeEmail }) {
|
|
507
|
+
const match = attendeeEmail
|
|
508
|
+
? ` if (email of att) is ${asString(attendeeEmail)} then set theAttendee to att`
|
|
509
|
+
: ` try
|
|
510
|
+
if (participation status of att) is needs action then set theAttendee to att
|
|
511
|
+
end try`;
|
|
512
|
+
|
|
513
|
+
return `${findEventHandler()}
|
|
514
|
+
|
|
515
|
+
set theEvent to atmFindEvent(${asString(eventId)})
|
|
516
|
+
tell application "Calendar"
|
|
517
|
+
set theAttendee to missing value
|
|
518
|
+
repeat with att in attendees of theEvent
|
|
519
|
+
${match}
|
|
520
|
+
end repeat
|
|
521
|
+
if theAttendee is missing value then error "ATTENDEE_NOT_FOUND"
|
|
522
|
+
try
|
|
523
|
+
set participation status of theAttendee to ${status}
|
|
524
|
+
on error errText
|
|
525
|
+
error "RSVP_NOT_SUPPORTED: " & errText
|
|
526
|
+
end try
|
|
527
|
+
set rsvpTitle to summary of theEvent
|
|
528
|
+
end tell
|
|
529
|
+
return rsvpTitle`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* RSVP to an invitation.
|
|
534
|
+
*
|
|
535
|
+
* Calendar.app exposes `participation status` on attendees but some macOS
|
|
536
|
+
* versions refuse to write it from AppleScript. When that happens the tool
|
|
537
|
+
* reports the refusal instead of silently doing nothing.
|
|
538
|
+
*/
|
|
539
|
+
export function calendarRsvp(args = {}) {
|
|
540
|
+
const action = "calendar_rsvp";
|
|
541
|
+
|
|
542
|
+
const eventId = validateEventId(args.event_id);
|
|
543
|
+
if (!eventId) {
|
|
544
|
+
return { ok: false, message: `${action} refused: event_id is required (the "Event ID" from calendar_date).` };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const responseRaw = args.response === undefined ? "" : String(args.response).toLowerCase();
|
|
548
|
+
if (!RSVP_RESPONSES.includes(responseRaw)) {
|
|
549
|
+
return { ok: false, message: `${action} refused: response must be one of: ${RSVP_RESPONSES.join(", ")}` };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let attendeeEmail = null;
|
|
553
|
+
if (args.attendee_email) {
|
|
554
|
+
const list = normalizeList(args.attendee_email);
|
|
555
|
+
attendeeEmail = list[0] || null;
|
|
556
|
+
if (attendeeEmail && !/^[^\s@]{1,64}@[^\s@]{1,190}$/.test(attendeeEmail)) {
|
|
557
|
+
return { ok: false, message: `${action} refused: attendee_email is not a valid address` };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const summary = `RSVP ${responseRaw} to event ${eventId}`;
|
|
562
|
+
const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
|
|
563
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
564
|
+
|
|
565
|
+
const result = runAppleScript(
|
|
566
|
+
buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
|
|
567
|
+
{ timeout: 60000, appName: "Calendar" }
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
if (!result.ok) {
|
|
571
|
+
const raw = String(result.error || "");
|
|
572
|
+
if (raw.includes("ATTENDEE_NOT_FOUND")) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
message: `${action} failed — attempted to ${summary}. No matching attendee was found on that event; pass attendee_email for the invited address.`
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (raw.includes("RSVP_NOT_SUPPORTED")) {
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
message: `${action} failed — attempted to ${summary}. Calendar.app refused to change the participation status on this macOS version; answer the invitation in Calendar directly.`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return { ok: false, message: failure(action, summary, result) };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
message: writeSuccessMessage(action, `RSVP ${responseRaw} recorded`, {
|
|
590
|
+
event_id: eventId,
|
|
591
|
+
title: truncate(result.output, 150) || undefined
|
|
592
|
+
})
|
|
593
|
+
};
|
|
594
|
+
}
|