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,481 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Write-tool smoke test for QA prove-out on a Node host (Mac Mini / LaunchAgent).
4
+ *
5
+ * Covers the Automation / privacy classes that gate this package's writes:
6
+ * - Mail compose (Automation → Mail.app). dry_run never talks to Mail, so
7
+ * this step runs a real `make new outgoing message` (then discards it).
8
+ * A hang or timeout is TCC / Automation denied, not "Mail.app missing".
9
+ * - Messages account/service lookup (Automation → Messages.app). Nothing
10
+ * is sent. A deny fails --apply the same way Mail does.
11
+ * - Contacts CRUD (AddressBook class, via Contacts.app)
12
+ * - Calendar CRUD (calendars class, via Calendar.app)
13
+ *
14
+ * Mail, Messages, Contacts, and Calendar must all pass on the Node host —
15
+ * that is the ship gate. A missing grant for any one of those four apps
16
+ * fails --apply. Contacts and Calendar CRUD are expected to fail under a
17
+ * host app that holds neither entitlement - that is a documented host
18
+ * limitation, not a package failure. Mail and Messages are separate Apple
19
+ * Events targets: a Contacts/Calendar grant does not include them.
20
+ *
21
+ * Default is a dry run: no contact or event is created, edited, or deleted.
22
+ * It is not a no-op, though: it reads contacts from the AddressBook database
23
+ * and calls calendar_list_calendars, which is a live Calendar.app query and
24
+ * therefore a real TCC touch that can prompt or be denied. A dry run treats
25
+ * that listing as advisory, so a denial does not report a failure for a run
26
+ * that changed nothing.
27
+ *
28
+ * Pass --apply to perform real create -> edit -> delete round trips.
29
+ *
30
+ * Usage:
31
+ * node scripts/smoke-writes.js # dry run, no changes
32
+ * node scripts/smoke-writes.js --apply # real CRUD, cleans up
33
+ * node scripts/smoke-writes.js --apply --keep # leaves the test items
34
+ * node scripts/smoke-writes.js --apply --calendar=Work
35
+ *
36
+ * Test items are clearly named and removed again unless --keep is set.
37
+ * No credentials, tokens, or personal data are involved.
38
+ */
39
+
40
+ import { loadContacts, getContactStats } from "../contacts.js";
41
+ import { probeSocket, defaultSocketPath } from "../lib/writeBridge.js";
42
+ import { dispatchWriteTool } from "../lib/writeTools.js";
43
+ import { probeMailAutomation } from "../lib/mailWrite.js";
44
+ import { probeMessagesAutomation } from "../lib/messagesWrite.js";
45
+ import { isIndexerMode } from "../lib/processMode.js";
46
+
47
+ export function parseSmokeArgs(argv = []) {
48
+ const calendarArg = argv.find((a) => a.startsWith("--calendar="));
49
+ return {
50
+ apply: argv.includes("--apply"),
51
+ keep: argv.includes("--keep"),
52
+ allowLocal: argv.includes("--allow-local"),
53
+ calendar: calendarArg ? calendarArg.slice("--calendar=".length) : null
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Ship-gate precondition.
59
+ *
60
+ * The smoke test must exercise the same path production MCP clients use, so
61
+ * a real run needs either the write bridge (writes execute inside the
62
+ * launchd-started daemon, where node is the responsible process) or an
63
+ * explicit acknowledgement that this process itself is the responsible one
64
+ * - which is only true when a parent like Terminal.app or launchd started
65
+ * it. Running in-process under a foreign parent (an embedded agent shell,
66
+ * for example) produces an AppleScript denial that says nothing about
67
+ * whether the package works.
68
+ *
69
+ * @returns {{ proceed: boolean, path: "daemon"|"local", reason: string }}
70
+ */
71
+ export function resolveSmokePath({ apply, bridgeUp, allowLocal, indexerMode }) {
72
+ if (bridgeUp) {
73
+ return { proceed: true, path: "daemon", reason: "write bridge is listening; writes execute in the indexer daemon" };
74
+ }
75
+ if (indexerMode) {
76
+ return { proceed: true, path: "local", reason: "this process is the indexer daemon" };
77
+ }
78
+ if (!apply) {
79
+ return { proceed: true, path: "local", reason: "dry run; nothing is executed" };
80
+ }
81
+ if (allowLocal) {
82
+ return { proceed: true, path: "local", reason: "--allow-local given; this process must be the responsible one" };
83
+ }
84
+ return {
85
+ proceed: false,
86
+ path: "local",
87
+ reason:
88
+ "no write bridge at the socket below and --apply was requested.\n" +
89
+ " The ship gate runs writes under launchd-owned node. Either:\n" +
90
+ " 1. start the indexer LaunchAgent (apple-tools-indexer) so writes route through the bridge, or\n" +
91
+ " 2. run this from Terminal.app, where node is the responsible process, and pass --allow-local.\n" +
92
+ " Executing in-process under another parent would only prove that parent's Automation rights,\n" +
93
+ " so this is refused rather than reported as a package failure."
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Pull the "event_id: <id>" value out of a write tool success message.
99
+ */
100
+ export function extractEventId(message) {
101
+ const match = String(message || "").match(/event_id:\s*([A-Za-z0-9._:@+-]+)/);
102
+ return match ? match[1] : null;
103
+ }
104
+
105
+ export function extractEventKitId(message) {
106
+ const match = String(message || "").match(/eventkit_id:\s*(\S+)/);
107
+ if (!match) return null;
108
+ return match[1].replace(/[.,;]+$/, "");
109
+ }
110
+
111
+ export function createdViaEventKit(message) {
112
+ const text = String(message || "");
113
+ return /via:\s*EventKit\b/i.test(text) && Boolean(extractEventKitId(text));
114
+ }
115
+
116
+ /**
117
+ * Prefer an On My Mac calendar so Calendar.app delete is reliable if EventKit
118
+ * remove still misses. EventKit add/remove is the primary path on iCloud
119
+ * (writeOnly can delete events it created). An explicit --calendar= wins.
120
+ */
121
+ export function pickSmokeCalendar(calendars, explicitName) {
122
+ if (explicitName) {
123
+ return { name: explicitName, reason: `--calendar=${explicitName}` };
124
+ }
125
+ const writable = (calendars || []).filter((c) => c.writable);
126
+ const local = writable.filter((c) => c.local);
127
+ if (local[0]) {
128
+ return { name: local[0].name, reason: "On My Mac (EventKit sourceType local)" };
129
+ }
130
+ if (writable[0]) {
131
+ return { name: writable[0].name, reason: "first writable calendar (no local calendar listed)" };
132
+ }
133
+ return { name: "Calendar", reason: "fallback name Calendar" };
134
+ }
135
+
136
+ /**
137
+ * Listing calendars is a live Calendar.app call even during a dry run, so a
138
+ * refusal there says something about this host - but it is not a failure of
139
+ * a run that created nothing. Only --apply, which must actually write,
140
+ * treats it as fatal.
141
+ *
142
+ * @returns {"error"|"warning"}
143
+ */
144
+ export function calendarListSeverity(apply) {
145
+ return apply ? "error" : "warning";
146
+ }
147
+
148
+ /**
149
+ * Mail compose is a live Apple Events call (dry_run of mail_send never
150
+ * touches Mail). On --apply a deny fails the ship gate. On a dry run it is
151
+ * advisory, like calendar_list_calendars, so a refused prompt is WARN.
152
+ *
153
+ * @returns {"error"|"warning"}
154
+ */
155
+ export function mailProbeSeverity(apply) {
156
+ return apply ? "error" : "warning";
157
+ }
158
+
159
+ export function messagesProbeSeverity(apply) {
160
+ return mailProbeSeverity(apply);
161
+ }
162
+
163
+ /**
164
+ * How smoke exercises Mail Apple Events.
165
+ *
166
+ * The compose-and-discard helper is smoke-script-only — not an MCP write
167
+ * tool. On --apply with the write bridge up, also run public mail_draft so
168
+ * launchd-owned node is fail-closed for Mail (same make new outgoing message
169
+ * verb; nothing is sent).
170
+ *
171
+ * @returns {{ useLocalHelper: boolean, useMailDraft: boolean, reason: string }}
172
+ */
173
+ export function planMailSmokeTouch({ apply, daemonPath }) {
174
+ if (apply && daemonPath) {
175
+ return {
176
+ useLocalHelper: true,
177
+ useMailDraft: true,
178
+ reason: "local compose-and-discard helper plus mail_draft via the write bridge (live Mail Apple Events; nothing is sent)"
179
+ };
180
+ }
181
+ return {
182
+ useLocalHelper: true,
183
+ useMailDraft: false,
184
+ reason: "smoke-script compose-and-discard helper (live Mail Apple Events; nothing is sent). mail_send dry_run never touches Mail."
185
+ };
186
+ }
187
+
188
+ /**
189
+ * How smoke exercises Messages Apple Events.
190
+ * Smoke-script-only helper; this gate never sends and is not an MCP tool.
191
+ *
192
+ * @returns {{ useLocalHelper: boolean, reason: string }}
193
+ */
194
+ export function planMessagesSmokeTouch() {
195
+ return {
196
+ useLocalHelper: true,
197
+ reason: "smoke-script Messages account lookup (live Apple Events; nothing is sent). messages_send dry_run never touches Messages."
198
+ };
199
+ }
200
+
201
+ export function mailDraftSmokeArgs(stamp) {
202
+ return {
203
+ to: [`atm-mail-probe-${stamp}@example.com`],
204
+ subject: `ATM Mail Automation probe ${stamp}`,
205
+ body: "Created by apple-tools-mcp smoke test; safe to delete from Drafts."
206
+ };
207
+ }
208
+
209
+ /**
210
+ * A start/end pair well in the future, so a smoke event never collides with
211
+ * anything real on the calendar.
212
+ */
213
+ export function smokeEventWindow(now = new Date()) {
214
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 400, 3, 0, 0, 0);
215
+ const pad = (v) => String(v).padStart(2, "0");
216
+ const fmt = (d, hour) =>
217
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hour)}:00`;
218
+ return { start: fmt(start, 3), end: fmt(start, 4) };
219
+ }
220
+
221
+ /**
222
+ * Pull the "contact_id: <id>" value out of a write tool success message.
223
+ */
224
+ export function extractContactId(message) {
225
+ const match = String(message || "").match(/contact_id:\s*([A-Za-z0-9._:-]+)/);
226
+ return match ? match[1] : null;
227
+ }
228
+
229
+ function line(label, value) {
230
+ console.log(`${label.padEnd(22)} ${value}`);
231
+ }
232
+
233
+ function step(name, result, severity = "error") {
234
+ const failed = result.ok === false;
235
+ const status = failed ? (severity === "warning" ? "WARN" : "FAIL") : result.planned ? "PLANNED" : "OK";
236
+ console.log(`\n[${status}] ${name}`);
237
+ console.log(` ${result.message}`);
238
+ return result;
239
+ }
240
+
241
+ async function main() {
242
+ const { apply, keep, calendar, allowLocal } = parseSmokeArgs(process.argv.slice(2));
243
+ const socketPath = defaultSocketPath();
244
+ const bridgeUp = await probeSocket(socketPath);
245
+ const indexerMode = isIndexerMode();
246
+ const route = resolveSmokePath({ apply, bridgeUp, allowLocal, indexerMode });
247
+
248
+ console.log("apple-tools-mcp write smoke test");
249
+ console.log("=".repeat(60));
250
+ line("Mode:", apply
251
+ ? "APPLY (will change Contacts and Calendar; Mail compose is live, nothing is sent)"
252
+ : "DRY RUN (no creates, edits, or deletes)");
253
+ line("Process:", indexerMode ? "indexer daemon" : "plain node / stdio");
254
+ line("Write bridge:", bridgeUp ? `listening at ${socketPath}` : `not listening (${socketPath})`);
255
+ line("Write path:", route.path === "daemon" ? "indexer daemon via write bridge" : "in this process");
256
+
257
+ if (!route.proceed) {
258
+ console.log(`\nRefusing to run: ${route.reason}`);
259
+ process.exitCode = 2;
260
+ return;
261
+ }
262
+
263
+ console.log(
264
+ "\nTCC note: macOS attributes this work to the process responsible for it.\n" +
265
+ "Contact reads use sqlite + Full Disk Access; the CRUD steps use Contacts.app\n" +
266
+ "and Calendar.app, gated by the AddressBook and calendars privacy classes.\n" +
267
+ "Mail compose is a separate Automation target (node → Mail.app). A hang or\n" +
268
+ "timeout there is TCC / Automation denied, not Mail.app missing.\n" +
269
+ `Writes take the same route production MCP clients take: ${route.reason}.`
270
+ );
271
+ if (!apply) {
272
+ console.log(
273
+ "Dry run: no creates, edits, or deletes for Contacts/Calendar. calendar_list_calendars\n" +
274
+ "still runs for real - it is a live Calendar.app query and a TCC touch -\n" +
275
+ "so a denial there is reported as a warning, not a failure.\n" +
276
+ "The Mail step also runs for real (make new outgoing message) because mail_send\n" +
277
+ "dry_run never touches Mail. A Mail deny is TCC / Automation denied; on a dry\n" +
278
+ "run it is a warning, on --apply it fails the ship gate."
279
+ );
280
+ }
281
+
282
+ // Every write goes through the production dispatcher, so the smoke test
283
+ // proves the path clients actually use rather than a private shortcut.
284
+ const run = (tool, args) => dispatchWriteTool(tool, args, { indexerMode, socketPath });
285
+
286
+ // Read side first: proves Full Disk Access independently of the write path.
287
+ console.log("\n--- Contacts read (sqlite + Full Disk Access) ---");
288
+ const contacts = loadContacts();
289
+ if (contacts.length === 0) {
290
+ console.log(" No contacts loaded. If you expected some, this is a Full Disk Access /");
291
+ console.log(" attribution problem on the responsible process, not the write entitlement.");
292
+ } else {
293
+ const stats = getContactStats();
294
+ line(" Contacts loaded:", String(stats.total));
295
+ line(" With unique ids:", String(contacts.filter((c) => c.uniqueId).length));
296
+ }
297
+
298
+ const stamp = new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14);
299
+ const common = apply ? { confirm: true } : { dry_run: true };
300
+ const results = [];
301
+
302
+ // Mail first so first-run Allow includes Mail in the same pass as Contacts/Calendar.
303
+ // Helpers are smoke-script-only — not MCP write tools. mail_send dry_run never talks to Mail.
304
+ console.log("\n--- Mail Automation (Mail.app compose / Apple Events) ---");
305
+ if (!apply) {
306
+ console.log(" (live make new outgoing message even on a dry run; mail_send dry_run never touches Mail)");
307
+ }
308
+
309
+ const mailPlan = planMailSmokeTouch({ apply, daemonPath: route.path === "daemon" });
310
+ const mailSeverity = mailProbeSeverity(apply);
311
+ const mailHelperResult = probeMailAutomation();
312
+ if (mailHelperResult.ok === false && mailSeverity === "warning") {
313
+ step("mail Automation compose", mailHelperResult, "warning");
314
+ console.log(" Warning only: mail_send dry_run never touches Mail. --apply fails closed if Mail Automation is still denied.");
315
+ } else {
316
+ results.push(step("mail Automation compose", mailHelperResult, mailSeverity));
317
+ }
318
+ if (mailPlan.useMailDraft) {
319
+ console.log(` ${mailPlan.reason}`);
320
+ results.push(step("mail_draft (daemon Mail Automation)", await run("mail_draft", mailDraftSmokeArgs(stamp))));
321
+ }
322
+
323
+ console.log("\n--- Messages Automation (Messages.app accounts / Apple Events) ---");
324
+ if (!apply) {
325
+ console.log(" (live Messages account lookup even on a dry run; messages_send dry_run never touches Messages; nothing is sent)");
326
+ }
327
+
328
+ const messagesSeverity = messagesProbeSeverity(apply);
329
+ const messagesResult = probeMessagesAutomation();
330
+
331
+ if (messagesResult.ok === false && messagesSeverity === "warning") {
332
+ step("messages Automation lookup", messagesResult, "warning");
333
+ console.log(" Warning only: messages_send dry_run never touches Messages. --apply fails closed if Messages Automation is still denied.");
334
+ } else {
335
+ results.push(step("messages Automation lookup", messagesResult, messagesSeverity));
336
+ }
337
+
338
+ console.log("\n--- Contacts CRUD (Contacts.app / AddressBook privacy class) ---");
339
+ const created = step("contacts_add", await run("contacts_add", {
340
+ first_name: "ATM Smoke",
341
+ last_name: `Test ${stamp}`,
342
+ organization: "apple-tools-mcp smoke test",
343
+ emails: [`atm-smoke-${stamp}@example.com`],
344
+ ...common
345
+ }));
346
+ results.push(created);
347
+
348
+ if (created.ok !== false) {
349
+ const contactId = apply ? extractContactId(created.message) : "ABCD1234-SMOKE:ABPerson";
350
+ if (apply && !contactId) {
351
+ console.log(" contacts_add reported success but returned no contact id; skipping edit/delete.");
352
+ results.push({ ok: false });
353
+ } else {
354
+ results.push(step("contacts_edit", await run("contacts_edit", {
355
+ contact_id: contactId,
356
+ job_title: "Smoke Tested",
357
+ ...common
358
+ })));
359
+
360
+ if (apply && keep) {
361
+ console.log(` Left contact ${contactId} in place (--keep). Delete it when you are done.`);
362
+ } else {
363
+ const removed = step("contacts_remove", await run("contacts_remove", { contact_id: contactId, ...common }));
364
+ results.push(removed);
365
+ if (apply && removed.ok === false) {
366
+ console.log(` Created ${contactId} but could not delete it. Remove it in Contacts.app.`);
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ // ---- Calendar: the other entitlement-gated write path ----
373
+ console.log("\n--- Calendar CRUD (Calendar.app / calendars privacy class) ---");
374
+ if (!apply) {
375
+ console.log(" (live Calendar.app query even on a dry run)");
376
+ }
377
+ const listSeverity = calendarListSeverity(apply);
378
+ const calendars = step("calendar_list_calendars", await run("calendar_list_calendars", {}), listSeverity);
379
+ if (calendars.ok === false && listSeverity === "warning") {
380
+ console.log(" Warning only: the dry run changed nothing, so a refused listing is not a failure.");
381
+ } else {
382
+ results.push(calendars);
383
+ }
384
+
385
+ const writable = (calendars.calendars || []).filter((c) => c.writable);
386
+ const picked = pickSmokeCalendar(calendars.calendars || [], calendar);
387
+ const targetCalendar = picked.name;
388
+ if (calendar && writable.length > 0 && !writable.some((c) => c.name === calendar)) {
389
+ console.log(` Warning: --calendar=${calendar} is not in the writable list; trying it anyway.`);
390
+ }
391
+ line(" Target calendar:", `${targetCalendar} (${picked.reason})`);
392
+
393
+ const window = smokeEventWindow();
394
+ const eventCreated = step("calendar_add", await run("calendar_add", {
395
+ calendar_name: targetCalendar,
396
+ title: `ATM smoke test ${stamp}`,
397
+ start: window.start,
398
+ end: window.end,
399
+ notes: "Created by apple-tools-mcp smoke test; safe to delete.",
400
+ alerts_minutes_before: [10],
401
+ ...common
402
+ }));
403
+ results.push(eventCreated);
404
+
405
+ if (eventCreated.ok !== false) {
406
+ const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
407
+ const extractedKitId = apply ? extractEventKitId(eventCreated.message) : "EK-SMOKE";
408
+ const eventKitId = extractedKitId || (eventId && eventId.includes(":") ? eventId : null);
409
+ if (apply && !createdViaEventKit(eventCreated.message)) {
410
+ console.log(" calendar_add did not print via: EventKit and eventkit_id.");
411
+ console.log(" Ship-gate refuses a Calendar.app-only create: writeOnly EventKit cannot delete those events, and Calendar.app delete hangs.");
412
+ results.push({ ok: false, message: "calendar_add ship-gate requires via: EventKit and eventkit_id" });
413
+ } else if (apply && !eventId) {
414
+ console.log(" calendar_add reported success but returned no event id; skipping edit/delete.");
415
+ results.push({ ok: false });
416
+ } else {
417
+ results.push(step("calendar_edit", await run("calendar_edit", {
418
+ event_id: eventId,
419
+ eventkit_id: eventKitId || undefined,
420
+ title: `ATM smoke test ${stamp} (edited)`,
421
+ ...common
422
+ })));
423
+
424
+ if (apply && keep) {
425
+ console.log(` Left event ${eventId} in place (--keep). Delete it when you are done.`);
426
+ } else {
427
+ const removed = step("calendar_remove", await run("calendar_remove", {
428
+ event_id: eventId,
429
+ eventkit_id: eventKitId || undefined,
430
+ calendar_name: targetCalendar,
431
+ ...common
432
+ }));
433
+ results.push(removed);
434
+ if (apply && removed.ok === false) {
435
+ console.log(` Created ${eventId} on ${targetCalendar} but could not delete it.`);
436
+ console.log(" add/edit succeeded, so this is a delete-path failure, not missing Calendar Automation.");
437
+ console.log(" Same write-bridge RPC as add/edit; Calendar.app has no remove/move-to-trash (delete + EventKit fallback).");
438
+ if (removed.diagnostics) {
439
+ console.log(` ${removed.diagnostics}`);
440
+ } else {
441
+ const rawLine = String(removed.message || "").match(/osascript kind=\S+ error=.*/);
442
+ if (rawLine) console.log(` ${rawLine[0]}`);
443
+ }
444
+ console.log(" Paste the osascript kind=/error= line if asking for another tip.");
445
+ console.log(" Remove the leftover event in Calendar.app.");
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ const failed = results.some((r) => r && r.ok === false);
452
+ console.log("\n" + "=".repeat(60));
453
+ if (failed) {
454
+ console.log("Result: FAILED - see the messages above.");
455
+ if (route.path === "local") {
456
+ console.log("These writes ran in this process, so the failure describes this process's");
457
+ console.log("Automation rights. Start the indexer LaunchAgent and re-run to test the");
458
+ console.log("shipping path, where writes execute under launchd-owned node.");
459
+ } else {
460
+ console.log("These writes ran inside the indexer daemon, so this is a real gate failure:");
461
+ console.log("grant the daemon's node binary Full Disk Access (reads) and Allow node in");
462
+ console.log("System Settings > Privacy & Security > Automation for Mail.app, Messages.app,");
463
+ console.log("Contacts.app, and Calendar.app. A hang or timeout on Mail compose is TCC /");
464
+ console.log("Automation denied, not Mail.app missing. Do not add node via + in the");
465
+ console.log("Contacts or Calendars privacy lists.");
466
+ }
467
+ process.exitCode = 1;
468
+ } else if (apply) {
469
+ console.log(`Result: PASS - Mail, Messages, Contacts, and Calendar Automation all work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
470
+ } else {
471
+ console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD and fail-closed Mail/Messages/Contacts/Calendar Automation.");
472
+ }
473
+ }
474
+
475
+ // Only run when invoked directly, so the helpers stay unit-testable.
476
+ if (process.argv[1] && process.argv[1].endsWith("smoke-writes.js")) {
477
+ main().catch((e) => {
478
+ console.error(`Smoke test error: ${e.message}`);
479
+ process.exit(1);
480
+ });
481
+ }
package/search.js CHANGED
@@ -1346,6 +1346,8 @@ export async function getCalendarDateResults(dateStr) {
1346
1346
 
1347
1347
  const formattedResults = events.map((row, idx) => ({
1348
1348
  index: idx + 1,
1349
+ // iCal UID: the id the calendar write tools address events by.
1350
+ eventId: row.uid || "",
1349
1351
  title: row.title || "No title",
1350
1352
  start: formatLocalDate(row.start) || row.start || "Unknown",
1351
1353
  startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
@@ -1512,6 +1514,7 @@ export function formatCalendarResults(searchResult) {
1512
1514
  result += `\nEvent: ${r.title}`;
1513
1515
  if (r.isAllDay) result += " (All Day)";
1514
1516
  result += `\nCalendar: ${r.calendar}\nStart: ${r.start}\nEnd: ${r.end}`;
1517
+ if (r.eventId) result += `\nEvent ID: ${r.eventId}`;
1515
1518
  if (r.location) result += `\nLocation: ${r.location}`;
1516
1519
  if (r.attendees && r.attendees.length > 0) {
1517
1520
  const attendeeList = r.attendees.map(a => `${a.name} (${a.status})`).join(", ");