dav-mcp 3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/package.json +80 -0
  5. package/src/error-handler.js +215 -0
  6. package/src/formatters.js +754 -0
  7. package/src/logger.js +144 -0
  8. package/src/server-http.js +402 -0
  9. package/src/server-stdio.js +225 -0
  10. package/src/tool-call-logger.js +148 -0
  11. package/src/tools/calendar/calendar-multi-get.js +38 -0
  12. package/src/tools/calendar/calendar-query.js +98 -0
  13. package/src/tools/calendar/create-event.js +79 -0
  14. package/src/tools/calendar/delete-calendar.js +36 -0
  15. package/src/tools/calendar/delete-event.js +38 -0
  16. package/src/tools/calendar/index.js +16 -0
  17. package/src/tools/calendar/list-calendars.js +21 -0
  18. package/src/tools/calendar/list-events.js +43 -0
  19. package/src/tools/calendar/make-calendar.js +80 -0
  20. package/src/tools/calendar/update-calendar.js +106 -0
  21. package/src/tools/calendar/update-event-fields.js +119 -0
  22. package/src/tools/calendar/update-event-raw.js +45 -0
  23. package/src/tools/contacts/addressbook-multi-get.js +38 -0
  24. package/src/tools/contacts/addressbook-query.js +85 -0
  25. package/src/tools/contacts/create-contact.js +84 -0
  26. package/src/tools/contacts/delete-contact.js +38 -0
  27. package/src/tools/contacts/index.js +13 -0
  28. package/src/tools/contacts/list-addressbooks.js +21 -0
  29. package/src/tools/contacts/list-contacts.js +32 -0
  30. package/src/tools/contacts/update-contact-fields.js +135 -0
  31. package/src/tools/contacts/update-contact-raw.js +45 -0
  32. package/src/tools/index.js +57 -0
  33. package/src/tools/shared/helpers.js +132 -0
  34. package/src/tools/todos/create-todo.js +101 -0
  35. package/src/tools/todos/delete-todo.js +38 -0
  36. package/src/tools/todos/index.js +12 -0
  37. package/src/tools/todos/list-todos.js +30 -0
  38. package/src/tools/todos/todo-multi-get.js +37 -0
  39. package/src/tools/todos/todo-query.js +112 -0
  40. package/src/tools/todos/update-todo-fields.js +119 -0
  41. package/src/tools/todos/update-todo-raw.js +46 -0
  42. package/src/tsdav-client.js +199 -0
  43. package/src/utils/tool-helpers.js +388 -0
  44. package/src/validation.js +245 -0
@@ -0,0 +1,754 @@
1
+ /**
2
+ * LLM-Friendly Output Formatters for tsdav-mcp
3
+ *
4
+ * This module provides formatters that convert raw CalDAV/CardDAV data
5
+ * into human-readable Markdown format optimized for LLM consumption.
6
+ *
7
+ * Uses RFC-compliant parsing:
8
+ * - ical.js for RFC 5545 (iCalendar) compliance
9
+ * - ical.js for RFC 6350 (vCard) compliance (supports v3.0 and v4.0)
10
+ */
11
+
12
+ import ICAL from 'ical.js';
13
+
14
+ /**
15
+ * Parse iCal data string to extract event properties (RFC 5545 compliant)
16
+ */
17
+ function parseICalEvent(icalData) {
18
+ try {
19
+ const jcalData = ICAL.parse(icalData);
20
+ const comp = new ICAL.Component(jcalData);
21
+ const vevent = comp.getFirstSubcomponent('vevent');
22
+
23
+ if (!vevent) {
24
+ return {};
25
+ }
26
+
27
+ const event = new ICAL.Event(vevent);
28
+
29
+ return {
30
+ summary: event.summary || '',
31
+ description: event.description || '',
32
+ location: event.location || '',
33
+ uid: event.uid || '',
34
+ dtstart: event.startDate,
35
+ dtend: event.endDate,
36
+ isRecurring: event.isRecurring(),
37
+ rrule: event.isRecurring() ? vevent.getFirstPropertyValue('rrule') : null,
38
+ organizer: vevent.getFirstPropertyValue('organizer'),
39
+ attendees: vevent.getAllProperties('attendee').map(att => ({
40
+ email: att.getFirstValue(),
41
+ role: att.getParameter('role'),
42
+ partstat: att.getParameter('partstat'),
43
+ cn: att.getParameter('cn'),
44
+ })),
45
+ alarms: vevent.getAllSubcomponents('valarm').map(valarm => ({
46
+ action: valarm.getFirstPropertyValue('action'),
47
+ trigger: valarm.getFirstPropertyValue('trigger'),
48
+ description: valarm.getFirstPropertyValue('description'),
49
+ })),
50
+ };
51
+ } catch (error) {
52
+ console.error('Error parsing iCal event:', error);
53
+ return {};
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Parse vCard data string to extract contact properties (RFC 6350 compliant)
59
+ */
60
+ function parseVCard(vcardData) {
61
+ try {
62
+ const jcard = ICAL.parse(vcardData);
63
+ const vcard = new ICAL.Component(jcard);
64
+
65
+ const contact = {
66
+ fullName: vcard.getFirstPropertyValue('fn') || '',
67
+ uid: vcard.getFirstPropertyValue('uid') || '',
68
+ };
69
+
70
+ // Parse structured name (N property)
71
+ const n = vcard.getFirstProperty('n');
72
+ if (n) {
73
+ const nameValue = n.getFirstValue();
74
+ contact.familyName = nameValue[0] || '';
75
+ contact.givenName = nameValue[1] || '';
76
+ contact.additionalNames = nameValue[2] || '';
77
+ contact.honorificPrefixes = nameValue[3] || '';
78
+ contact.honorificSuffixes = nameValue[4] || '';
79
+ }
80
+
81
+ // Parse all emails
82
+ const emails = vcard.getAllProperties('email');
83
+ if (emails && emails.length > 0) {
84
+ contact.emails = emails.map(e => ({
85
+ value: e.getFirstValue(),
86
+ type: e.getParameter('type') ? [e.getParameter('type')] : [],
87
+ }));
88
+ }
89
+
90
+ // Parse all phone numbers
91
+ const tels = vcard.getAllProperties('tel');
92
+ if (tels && tels.length > 0) {
93
+ contact.phones = tels.map(t => ({
94
+ value: t.getFirstValue(),
95
+ type: t.getParameter('type') ? [t.getParameter('type')] : [],
96
+ }));
97
+ }
98
+
99
+ // Parse all addresses
100
+ const adrs = vcard.getAllProperties('adr');
101
+ if (adrs && adrs.length > 0) {
102
+ contact.addresses = adrs.map(a => {
103
+ const adrValue = a.getFirstValue();
104
+ return {
105
+ poBox: adrValue[0] || '',
106
+ extendedAddress: adrValue[1] || '',
107
+ streetAddress: adrValue[2] || '',
108
+ locality: adrValue[3] || '',
109
+ region: adrValue[4] || '',
110
+ postalCode: adrValue[5] || '',
111
+ country: adrValue[6] || '',
112
+ type: a.getParameter('type') ? [a.getParameter('type')] : [],
113
+ };
114
+ });
115
+ }
116
+
117
+ // Parse organization
118
+ const org = vcard.getFirstProperty('org');
119
+ if (org) {
120
+ const orgValue = org.getFirstValue();
121
+ contact.organization = Array.isArray(orgValue) ? orgValue.join(', ') : orgValue;
122
+ }
123
+
124
+ // Parse note
125
+ const note = vcard.getFirstPropertyValue('note');
126
+ if (note) {
127
+ contact.note = note;
128
+ }
129
+
130
+ return contact;
131
+ } catch (error) {
132
+ console.error('Error parsing vCard:', error);
133
+ return {};
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Format ICAL.Time to human-readable format with proper timezone support
139
+ */
140
+ function formatDateTime(icalTime) {
141
+ if (!icalTime) return '';
142
+
143
+ try {
144
+ // Convert ICAL.Time to JavaScript Date
145
+ const jsDate = icalTime.toJSDate();
146
+
147
+ const dateStr = jsDate.toLocaleDateString('en-US', {
148
+ year: 'numeric',
149
+ month: 'long',
150
+ day: 'numeric',
151
+ timeZone: icalTime.timezone === 'UTC' ? 'UTC' : undefined,
152
+ });
153
+
154
+ const timeStr = jsDate.toLocaleTimeString('en-US', {
155
+ hour: '2-digit',
156
+ minute: '2-digit',
157
+ timeZoneName: 'short',
158
+ timeZone: icalTime.timezone === 'UTC' ? 'UTC' : undefined,
159
+ });
160
+
161
+ return `${dateStr}, ${timeStr}`;
162
+ } catch (error) {
163
+ console.error('Error formatting datetime:', error);
164
+ return '';
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Format a single calendar event to Markdown
170
+ */
171
+ export function formatEvent(event, calendarName = 'Unknown Calendar') {
172
+ const parsed = parseICalEvent(event.data);
173
+
174
+ const startDate = formatDateTime(parsed.dtstart);
175
+ const endDate = formatDateTime(parsed.dtend);
176
+
177
+ let output = `## ${parsed.summary || 'Untitled Event'}\n\n`;
178
+ output += `- **When**: ${startDate}`;
179
+
180
+ if (endDate && endDate !== startDate) {
181
+ output += ` to ${endDate}`;
182
+ }
183
+ output += '\n';
184
+
185
+ if (parsed.location) {
186
+ output += `- **Where**: ${parsed.location}\n`;
187
+ }
188
+
189
+ if (parsed.description) {
190
+ output += `- **Description**: ${parsed.description}\n`;
191
+ }
192
+
193
+ // Show recurrence info if event is recurring
194
+ if (parsed.isRecurring && parsed.rrule) {
195
+ output += `- **Recurring**: ${parsed.rrule.toString()}\n`;
196
+ }
197
+
198
+ // Show organizer if present
199
+ if (parsed.organizer) {
200
+ const organizerEmail = parsed.organizer.replace('mailto:', '');
201
+ output += `- **Organizer**: ${organizerEmail}\n`;
202
+ }
203
+
204
+ // Show attendees if present
205
+ if (parsed.attendees && parsed.attendees.length > 0) {
206
+ output += `- **Attendees**: ${parsed.attendees.length} person(s)\n`;
207
+ parsed.attendees.forEach(att => {
208
+ const email = att.email ? att.email.replace('mailto:', '') : '';
209
+ const name = att.cn || email;
210
+ const status = att.partstat ? ` (${att.partstat})` : '';
211
+ output += ` - ${name}${status}\n`;
212
+ });
213
+ }
214
+
215
+ // Show alarms if present
216
+ if (parsed.alarms && parsed.alarms.length > 0) {
217
+ output += `- **Reminders**: ${parsed.alarms.length} alarm(s)\n`;
218
+ parsed.alarms.forEach(alarm => {
219
+ output += ` - ${alarm.action}: ${alarm.trigger ? alarm.trigger.toString() : 'Unknown trigger'}\n`;
220
+ });
221
+ }
222
+
223
+ output += `- **Calendar**: ${calendarName}\n`;
224
+ output += `- **URL**: ${event.url}\n`;
225
+
226
+ return output;
227
+ }
228
+
229
+ /**
230
+ * Format a list of calendar events to LLM-friendly Markdown
231
+ */
232
+ export function formatEventList(events, calendarName = 'Unknown Calendar') {
233
+ if (!events || events.length === 0) {
234
+ return {
235
+ content: [{
236
+ type: 'text',
237
+ text: 'No events found.'
238
+ }]
239
+ };
240
+ }
241
+
242
+ let output = `Found events: **${events.length}**\n\n`;
243
+
244
+ events.forEach((event, index) => {
245
+ output += `### ${index + 1}. `;
246
+ output += formatEvent(event, calendarName).replace(/^## /, '') + '\n';
247
+ });
248
+
249
+ output += `---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
250
+ output += JSON.stringify(events.map(e => ({
251
+ url: e.url,
252
+ etag: e.etag,
253
+ data: e.data
254
+ })), null, 2);
255
+ output += '\n```\n</details>';
256
+
257
+ return {
258
+ content: [{
259
+ type: 'text',
260
+ text: output
261
+ }]
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Format a single contact to Markdown
267
+ */
268
+ export function formatContact(contact, addressBookName = 'Unknown Address Book') {
269
+ const parsed = parseVCard(contact.data);
270
+
271
+ let output = `## ${parsed.fullName || 'Unnamed Contact'}\n\n`;
272
+
273
+ // Show structured name if available
274
+ if (parsed.givenName || parsed.familyName) {
275
+ const nameParts = [];
276
+ if (parsed.honorificPrefixes) nameParts.push(parsed.honorificPrefixes);
277
+ if (parsed.givenName) nameParts.push(parsed.givenName);
278
+ if (parsed.additionalNames) nameParts.push(parsed.additionalNames);
279
+ if (parsed.familyName) nameParts.push(parsed.familyName);
280
+ if (parsed.honorificSuffixes) nameParts.push(parsed.honorificSuffixes);
281
+ if (nameParts.length > 0) {
282
+ output += `- **Full Name**: ${nameParts.join(' ')}\n`;
283
+ }
284
+ }
285
+
286
+ if (parsed.organization) {
287
+ output += `- **Organization**: ${parsed.organization}\n`;
288
+ }
289
+
290
+ // Show all emails
291
+ if (parsed.emails && parsed.emails.length > 0) {
292
+ if (parsed.emails.length === 1) {
293
+ const emailType = parsed.emails[0].type.length > 0 ? ` (${parsed.emails[0].type.join(', ')})` : '';
294
+ output += `- **Email**: ${parsed.emails[0].value}${emailType}\n`;
295
+ } else {
296
+ output += `- **Emails**: ${parsed.emails.length} email(s)\n`;
297
+ parsed.emails.forEach(email => {
298
+ const emailType = email.type.length > 0 ? ` (${email.type.join(', ')})` : '';
299
+ output += ` - ${email.value}${emailType}\n`;
300
+ });
301
+ }
302
+ }
303
+
304
+ // Show all phones
305
+ if (parsed.phones && parsed.phones.length > 0) {
306
+ if (parsed.phones.length === 1) {
307
+ const phoneType = parsed.phones[0].type.length > 0 ? ` (${parsed.phones[0].type.join(', ')})` : '';
308
+ output += `- **Phone**: ${parsed.phones[0].value}${phoneType}\n`;
309
+ } else {
310
+ output += `- **Phones**: ${parsed.phones.length} phone(s)\n`;
311
+ parsed.phones.forEach(phone => {
312
+ const phoneType = phone.type.length > 0 ? ` (${phone.type.join(', ')})` : '';
313
+ output += ` - ${phone.value}${phoneType}\n`;
314
+ });
315
+ }
316
+ }
317
+
318
+ // Show all addresses
319
+ if (parsed.addresses && parsed.addresses.length > 0) {
320
+ output += `- **Addresses**: ${parsed.addresses.length} address(es)\n`;
321
+ parsed.addresses.forEach(addr => {
322
+ const addrParts = [];
323
+ if (addr.streetAddress) addrParts.push(addr.streetAddress);
324
+ if (addr.locality) addrParts.push(addr.locality);
325
+ if (addr.region) addrParts.push(addr.region);
326
+ if (addr.postalCode) addrParts.push(addr.postalCode);
327
+ if (addr.country) addrParts.push(addr.country);
328
+ const addrType = addr.type.length > 0 ? ` (${addr.type.join(', ')})` : '';
329
+ if (addrParts.length > 0) {
330
+ output += ` - ${addrParts.join(', ')}${addrType}\n`;
331
+ }
332
+ });
333
+ }
334
+
335
+ if (parsed.note) {
336
+ output += `- **Note**: ${parsed.note}\n`;
337
+ }
338
+
339
+ output += `- **Address Book**: ${addressBookName}\n`;
340
+ output += `- **URL**: ${contact.url}\n`;
341
+
342
+ return output;
343
+ }
344
+
345
+ /**
346
+ * Format a list of contacts to LLM-friendly Markdown
347
+ */
348
+ export function formatContactList(contacts, addressBookName = 'Unknown Address Book') {
349
+ if (!contacts || contacts.length === 0) {
350
+ return {
351
+ content: [{
352
+ type: 'text',
353
+ text: `No contacts found in ${addressBookName}.
354
+
355
+ 💡 **Next steps**:
356
+ - Try broader search: use addressbook_query with partial name
357
+ - List all contacts: use list_contacts to see available names
358
+ - Create new contact: use create_contact if contact doesn't exist yet
359
+
360
+ 📝 **Available address books**: Use list_addressbooks to see all address books`
361
+ }]
362
+ };
363
+ }
364
+
365
+ let output = `Found contacts: **${contacts.length}**\n\n`;
366
+
367
+ contacts.forEach((contact, index) => {
368
+ output += `### ${index + 1}. `;
369
+ output += formatContact(contact, addressBookName).replace(/^## /, '') + '\n';
370
+ });
371
+
372
+ output += `---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
373
+ output += JSON.stringify(contacts.map(c => ({
374
+ url: c.url,
375
+ etag: c.etag,
376
+ data: c.data
377
+ })), null, 2);
378
+ output += '\n```\n</details>';
379
+
380
+ // Add next action hints
381
+ output += `\n💡 **What you can do next**:
382
+ - Update contact: use update_contact with URL and ETAG from above
383
+ - Delete contact: use delete_contact with URL and ETAG from above
384
+ - Get full details: Contact data already complete above`;
385
+
386
+ return {
387
+ content: [{
388
+ type: 'text',
389
+ text: output
390
+ }]
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Helper: Extract string value from property (handles both string and object)
396
+ * tsdav sometimes returns { _text: "value" } instead of "value"
397
+ */
398
+ function extractPropertyValue(prop) {
399
+ if (!prop) return '';
400
+ if (typeof prop === 'string') return prop;
401
+ if (typeof prop === 'object') {
402
+ return prop._text || prop.value || String(prop);
403
+ }
404
+ return String(prop);
405
+ }
406
+
407
+ /**
408
+ * Format calendar list to LLM-friendly Markdown
409
+ */
410
+ export function formatCalendarList(calendars) {
411
+ if (!calendars || calendars.length === 0) {
412
+ return {
413
+ content: [{
414
+ type: 'text',
415
+ text: 'No calendars found.'
416
+ }]
417
+ };
418
+ }
419
+
420
+ let output = `Available calendars: **${calendars.length}**\n\n`;
421
+
422
+ calendars.forEach((cal, index) => {
423
+ const displayName = extractPropertyValue(cal.displayName) || 'Unnamed Calendar';
424
+ output += `### ${index + 1}. ${displayName}\n\n`;
425
+
426
+ if (cal.description) {
427
+ output += `- **Description**: ${cal.description}\n`;
428
+ }
429
+
430
+ if (cal.components) {
431
+ output += `- **Components**: ${cal.components.join(', ')}\n`;
432
+ }
433
+
434
+ if (cal.calendarColor) {
435
+ output += `- **Color**: ${cal.calendarColor}\n`;
436
+ }
437
+
438
+ output += `- **URL**: ${cal.url}\n\n`;
439
+ });
440
+
441
+ output += `---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
442
+ output += JSON.stringify(calendars.map(cal => ({
443
+ displayName: cal.displayName,
444
+ url: cal.url,
445
+ components: cal.components,
446
+ calendarColor: cal.calendarColor,
447
+ description: cal.description,
448
+ })), null, 2);
449
+ output += '\n```\n</details>';
450
+
451
+ return {
452
+ content: [{
453
+ type: 'text',
454
+ text: output
455
+ }]
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Format address book list to LLM-friendly Markdown
461
+ */
462
+ export function formatAddressBookList(addressBooks) {
463
+ if (!addressBooks || addressBooks.length === 0) {
464
+ return {
465
+ content: [{
466
+ type: 'text',
467
+ text: 'No address books found.'
468
+ }]
469
+ };
470
+ }
471
+
472
+ let output = `Available address books: **${addressBooks.length}**\n\n`;
473
+
474
+ addressBooks.forEach((ab, index) => {
475
+ output += `### ${index + 1}. ${ab.displayName || 'Unnamed Address Book'}\n\n`;
476
+
477
+ if (ab.description) {
478
+ output += `- **Description**: ${ab.description}\n`;
479
+ }
480
+
481
+ output += `- **URL**: ${ab.url}\n\n`;
482
+ });
483
+
484
+ output += `---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
485
+ output += JSON.stringify(addressBooks.map(ab => ({
486
+ displayName: ab.displayName,
487
+ url: ab.url,
488
+ description: ab.description,
489
+ })), null, 2);
490
+ output += '\n```\n</details>';
491
+
492
+ return {
493
+ content: [{
494
+ type: 'text',
495
+ text: output
496
+ }]
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Format success message for create/update/delete operations
502
+ */
503
+ export function formatSuccess(operation, details = {}) {
504
+ let output = `✅ **${operation} successful**\n\n`;
505
+
506
+ if (details.url) {
507
+ output += `- **URL**: ${details.url}\n`;
508
+ }
509
+
510
+ if (details.etag) {
511
+ output += `- **ETag**: ${details.etag}\n`;
512
+ }
513
+
514
+ if (details.message) {
515
+ output += `- **Message**: ${details.message}\n`;
516
+ }
517
+
518
+ output += `\n---\n<details>\n<summary>Rohdaten (JSON)</summary>\n\n\`\`\`json\n`;
519
+ output += JSON.stringify({ success: true, ...details }, null, 2);
520
+ output += '\n```\n</details>';
521
+
522
+ return {
523
+ content: [{
524
+ type: 'text',
525
+ text: output
526
+ }]
527
+ };
528
+ }
529
+
530
+ export function formatCalendarUpdateSuccess(calendar, updatedFields) {
531
+ let output = `✅ **Calendar updated successfully**\n\n`;
532
+
533
+ const displayName = extractPropertyValue(calendar.displayName) || 'Unnamed Calendar';
534
+ output += `- **Calendar**: ${displayName}\n`;
535
+ output += `- **URL**: ${calendar.url}\n`;
536
+
537
+ if (updatedFields && Object.keys(updatedFields).length > 0) {
538
+ output += `\n**Updated fields:**\n`;
539
+ if (updatedFields.display_name) {
540
+ output += `- Display name: ${updatedFields.display_name}\n`;
541
+ }
542
+ if (updatedFields.description) {
543
+ output += `- Description: ${updatedFields.description}\n`;
544
+ }
545
+ if (updatedFields.color) {
546
+ output += `- Color: ${updatedFields.color}\n`;
547
+ }
548
+ if (updatedFields.timezone) {
549
+ output += `- Timezone: ${updatedFields.timezone}\n`;
550
+ }
551
+ }
552
+
553
+ output += `\n---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
554
+ output += JSON.stringify({ success: true, calendar, updatedFields }, null, 2);
555
+ output += '\n```\n</details>';
556
+
557
+ return {
558
+ content: [{
559
+ type: 'text',
560
+ text: output
561
+ }]
562
+ };
563
+ }
564
+
565
+ export function formatCalendarDeleteSuccess(calendarUrl) {
566
+ let output = `✅ **Calendar deleted successfully**\n\n`;
567
+
568
+ output += `⚠️ **Warning**: The calendar and all its events have been permanently deleted.\n\n`;
569
+ output += `- **Deleted URL**: ${calendarUrl}\n`;
570
+
571
+ output += `\n---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
572
+ output += JSON.stringify({ success: true, deleted: true, url: calendarUrl }, null, 2);
573
+ output += '\n```\n</details>';
574
+
575
+ return {
576
+ content: [{
577
+ type: 'text',
578
+ text: output
579
+ }]
580
+ };
581
+ }
582
+
583
+ /**
584
+ * Parse VTODO (task) from iCal data
585
+ */
586
+ function parseVTodo(icalData) {
587
+ try {
588
+ const jcalData = ICAL.parse(icalData);
589
+ const comp = new ICAL.Component(jcalData);
590
+ const vtodo = comp.getFirstSubcomponent('vtodo');
591
+
592
+ if (!vtodo) {
593
+ return {};
594
+ }
595
+
596
+ return {
597
+ uid: vtodo.getFirstPropertyValue('uid') || '',
598
+ summary: vtodo.getFirstPropertyValue('summary') || '',
599
+ description: vtodo.getFirstPropertyValue('description') || '',
600
+ status: vtodo.getFirstPropertyValue('status') || 'NEEDS-ACTION',
601
+ priority: vtodo.getFirstPropertyValue('priority') || 0,
602
+ percentComplete: vtodo.getFirstPropertyValue('percent-complete') || 0,
603
+ due: vtodo.getFirstPropertyValue('due'),
604
+ completed: vtodo.getFirstPropertyValue('completed'),
605
+ dtstart: vtodo.getFirstPropertyValue('dtstart'),
606
+ };
607
+ } catch (error) {
608
+ console.error('Error parsing VTODO:', error);
609
+ return {};
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Get emoji for todo status
615
+ */
616
+ function getStatusEmoji(status) {
617
+ const statusMap = {
618
+ 'NEEDS-ACTION': '📋',
619
+ 'IN-PROCESS': '🔄',
620
+ 'COMPLETED': '✅',
621
+ 'CANCELLED': '❌',
622
+ };
623
+ return statusMap[status] || '📋';
624
+ }
625
+
626
+ /**
627
+ * Format priority (0-9 where 0=undefined, 1=highest, 9=lowest)
628
+ */
629
+ function formatPriority(priority) {
630
+ if (priority === 0 || priority === undefined) return 'None';
631
+ if (priority >= 1 && priority <= 3) return `🔴 High (${priority})`;
632
+ if (priority >= 4 && priority <= 6) return `🟡 Medium (${priority})`;
633
+ return `🟢 Low (${priority})`;
634
+ }
635
+
636
+ /**
637
+ * Format a single todo to Markdown
638
+ */
639
+ export function formatTodo(todo, calendarName = 'Unknown Calendar') {
640
+ const parsed = parseVTodo(todo.data);
641
+ const statusEmoji = getStatusEmoji(parsed.status);
642
+
643
+ let output = `## ${statusEmoji} ${parsed.summary || 'Untitled Task'}\n\n`;
644
+
645
+ output += `- **Status**: ${parsed.status}\n`;
646
+
647
+ if (parsed.due) {
648
+ output += `- **Due**: ${formatDateTime(parsed.due)}\n`;
649
+ }
650
+
651
+ if (parsed.priority && parsed.priority !== 0) {
652
+ output += `- **Priority**: ${formatPriority(parsed.priority)}\n`;
653
+ }
654
+
655
+ if (parsed.percentComplete > 0) {
656
+ output += `- **Progress**: ${parsed.percentComplete}%\n`;
657
+ }
658
+
659
+ if (parsed.description) {
660
+ output += `- **Description**: ${parsed.description}\n`;
661
+ }
662
+
663
+ if (parsed.dtstart) {
664
+ output += `- **Start**: ${formatDateTime(parsed.dtstart)}\n`;
665
+ }
666
+
667
+ if (parsed.completed) {
668
+ output += `- **Completed**: ${formatDateTime(parsed.completed)}\n`;
669
+ }
670
+
671
+ output += `- **Calendar**: ${calendarName}\n`;
672
+ output += `- **URL**: ${todo.url}\n`;
673
+ output += `- **ETag**: ${todo.etag} *(required for updates)*\n`;
674
+
675
+ return output;
676
+ }
677
+
678
+ /**
679
+ * Format a list of todos to LLM-friendly Markdown
680
+ */
681
+ export function formatTodoList(todos, calendarName = 'Unknown Calendar') {
682
+ if (!todos || todos.length === 0) {
683
+ return {
684
+ content: [{
685
+ type: 'text',
686
+ text: 'No todos found.'
687
+ }]
688
+ };
689
+ }
690
+
691
+ let output = `Found todos: **${todos.length}**\n\n`;
692
+
693
+ todos.forEach((todo, index) => {
694
+ output += `### ${index + 1}. `;
695
+ output += formatTodo(todo, calendarName).replace(/^## /, '') + '\n';
696
+ });
697
+
698
+ output += `---\n<details>\n<summary>Raw Data (JSON)</summary>\n\n\`\`\`json\n`;
699
+ output += JSON.stringify(todos.map(t => ({
700
+ url: t.url,
701
+ etag: t.etag,
702
+ data: t.data
703
+ })), null, 2);
704
+ output += '\n```\n</details>';
705
+
706
+ return {
707
+ content: [{
708
+ type: 'text',
709
+ text: output
710
+ }]
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Format error message in a user-friendly way
716
+ */
717
+ export function formatError(error, context = '') {
718
+ let output = `❌ **Error${context ? ` in ${context}` : ''}**\n\n`;
719
+
720
+ // Provide actionable error messages
721
+ const errorMsg = error.message || String(error);
722
+
723
+ if (errorMsg.includes('not found')) {
724
+ output += `The specified resource was not found.\n\n`;
725
+ output += `**Possible solutions:**\n`;
726
+ output += `- Check the URL\n`;
727
+ output += `- Ensure the resource exists\n`;
728
+ output += `- Refresh the resource list\n`;
729
+ } else if (errorMsg.includes('auth') || errorMsg.includes('401')) {
730
+ output += `Authentication failed.\n\n`;
731
+ output += `**Possible solutions:**\n`;
732
+ output += `- Check username and password\n`;
733
+ output += `- Ensure the server is reachable\n`;
734
+ output += `- Verify server settings in .env file\n`;
735
+ } else if (errorMsg.includes('etag') || errorMsg.includes('412')) {
736
+ output += `The resource was modified in the meantime.\n\n`;
737
+ output += `**Possible solutions:**\n`;
738
+ output += `- Reload the current version of the resource\n`;
739
+ output += `- Use the current ETag\n`;
740
+ } else {
741
+ output += `${errorMsg}\n`;
742
+ }
743
+
744
+ output += `\n---\n<details>\n<summary>Technical Details</summary>\n\n\`\`\`\n`;
745
+ output += error.stack || errorMsg;
746
+ output += '\n```\n</details>';
747
+
748
+ return {
749
+ content: [{
750
+ type: 'text',
751
+ text: output
752
+ }]
753
+ };
754
+ }