icloud-mcp 2.5.0 → 2.7.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/lib/caldav.js CHANGED
@@ -14,7 +14,7 @@ function getCredentials() {
14
14
  return { user, auth: Buffer.from(`${user}:${pass}`).toString('base64') };
15
15
  }
16
16
 
17
- async function davRequest(method, url, opts = {}) {
17
+ async function defaultDavRequest(method, url, opts = {}) {
18
18
  const { auth } = getCredentials();
19
19
  const headers = {
20
20
  Authorization: `Basic ${auth}`,
@@ -27,6 +27,17 @@ async function davRequest(method, url, opts = {}) {
27
27
  return { status: res.status, etag: res.headers.get('etag'), body: text };
28
28
  }
29
29
 
30
+ let davRequest = defaultDavRequest;
31
+
32
+ export function setCalDavRequestForTests(fn) {
33
+ davRequest = fn || defaultDavRequest;
34
+ _discoveryCache = null;
35
+ }
36
+
37
+ export function setCalDavDiscoveryForTests(cache) {
38
+ _discoveryCache = cache;
39
+ }
40
+
30
41
  function propfindBody(props) {
31
42
  return `<?xml version="1.0" encoding="UTF-8"?>\n<A:propfind xmlns:A="DAV:"><A:prop>${props}</A:prop></A:propfind>`;
32
43
  }
@@ -155,12 +166,17 @@ function parseVEvent(ical) {
155
166
  const lines = unfolded.split(/\r?\n/);
156
167
 
157
168
  let inEvent = false;
169
+ let subDepth = 0; // track nested components inside VEVENT (e.g. VALARM)
158
170
  const event = {};
159
171
 
160
172
  for (const line of lines) {
161
- if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
173
+ if (line === 'BEGIN:VEVENT') { inEvent = true; subDepth = 0; continue; }
162
174
  if (line === 'END:VEVENT') { inEvent = false; continue; }
163
175
  if (!inEvent) continue;
176
+ // Skip lines inside nested sub-components (VALARM, etc.)
177
+ if (line.startsWith('BEGIN:')) { subDepth++; continue; }
178
+ if (line.startsWith('END:')) { subDepth--; continue; }
179
+ if (subDepth > 0) continue;
164
180
 
165
181
  const colonIdx = line.indexOf(':');
166
182
  if (colonIdx < 0) continue;
@@ -350,6 +366,78 @@ export async function listCalendars() {
350
366
  return { calendars, count: calendars.length };
351
367
  }
352
368
 
369
+ // Resolve a calendar by exact name or calendarId. Refuses ambiguous names so a
370
+ // destructive call never guesses between two calendars with the same title.
371
+ async function findCalendar({ name, calendarId }) {
372
+ if (!name && !calendarId) throw new Error('Pass a calendar name or calendarId');
373
+ const { calendars } = await listCalendars();
374
+ const matches = calendarId
375
+ ? calendars.filter(c => c.calendarId === calendarId)
376
+ : calendars.filter(c => c.name === name);
377
+ if (matches.length === 0) throw new Error(`Calendar not found: ${calendarId || name}`);
378
+ if (matches.length > 1) throw new Error(`More than one calendar is named "${name}"; pass its calendarId instead`);
379
+ return matches[0];
380
+ }
381
+
382
+ // Counts every event resource in the collection, past and future, not just a date range.
383
+ async function countCalendarItems(calendarId) {
384
+ const { dataHost, calendarsPath } = await discover();
385
+ const resp = await davRequest('PROPFIND', `${dataHost}${calendarsPath}${calendarId}/`, {
386
+ depth: 1,
387
+ contentType: 'application/xml; charset=utf-8',
388
+ body: propfindBody('<A:getetag/>'),
389
+ });
390
+ if (resp.status === 404) throw new Error(`Calendar not found: ${calendarId}`);
391
+ return splitResponses(resp.body).filter(block => /\.ics</i.test(block)).length;
392
+ }
393
+
394
+ export async function createCalendar(name) {
395
+ if (!name || !name.trim()) throw new Error('Calendar name is required');
396
+ const { calendars } = await listCalendars();
397
+ if (calendars.some(c => c.name === name)) throw new Error(`A calendar named "${name}" already exists`);
398
+
399
+ const { dataHost, calendarsPath } = await discover();
400
+ const calendarId = randomUUID().toUpperCase();
401
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
402
+ <C:mkcalendar xmlns:A="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
403
+ <A:set><A:prop>
404
+ <A:displayname>${name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</A:displayname>
405
+ <C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
406
+ </A:prop></A:set>
407
+ </C:mkcalendar>`;
408
+ const resp = await davRequest('MKCALENDAR', `${dataHost}${calendarsPath}${calendarId}/`, {
409
+ contentType: 'application/xml; charset=utf-8',
410
+ body,
411
+ });
412
+ if (resp.status !== 201) throw new Error(`CalDAV MKCALENDAR failed: ${resp.status}`);
413
+ return { created: true, calendarId, name };
414
+ }
415
+
416
+ // Deletes an empty event calendar. Refuses calendars that still hold events
417
+ // (delete those first with bulk_delete_events) and Reminders lists (VTODO).
418
+ export async function deleteCalendar({ name, calendarId }, dryRun = false) {
419
+ const cal = await findCalendar({ name, calendarId });
420
+ if (!cal.supportedTypes.includes('VEVENT')) {
421
+ throw new Error(`"${cal.name}" is not an event calendar; use delete_reminder_list for Reminders lists`);
422
+ }
423
+ const eventCount = await countCalendarItems(cal.calendarId);
424
+ if (dryRun) {
425
+ return {
426
+ dryRun: true,
427
+ wouldDelete: eventCount === 0,
428
+ eventCount,
429
+ changes: [{ action: 'delete_calendar', calendarId: cal.calendarId, name: cal.name, eventCount }],
430
+ };
431
+ }
432
+ if (eventCount > 0) {
433
+ throw new Error(`Calendar "${cal.name}" still has ${eventCount} event(s); delete them first`);
434
+ }
435
+ const { dataHost, calendarsPath } = await discover();
436
+ const resp = await davRequest('DELETE', `${dataHost}${calendarsPath}${cal.calendarId}/`);
437
+ if (resp.status !== 204 && resp.status !== 200) throw new Error(`CalDAV DELETE failed: ${resp.status}`);
438
+ return { deleted: true, calendarId: cal.calendarId, name: cal.name };
439
+ }
440
+
353
441
  export async function listEvents(calendarId, since = null, before = null, limit = 50) {
354
442
  const { dataHost, calendarsPath } = await discover();
355
443
 
@@ -441,7 +529,24 @@ export async function updateEvent(calendarId, eventId, fields) {
441
529
  return { updated: true, eventId, calendarId, etag: resp.etag };
442
530
  }
443
531
 
444
- export async function deleteEvent(calendarId, eventId) {
532
+ export async function deleteEvent(calendarId, eventId, dryRun = false) {
533
+ if (dryRun) {
534
+ const event = await getEvent(calendarId, eventId);
535
+ return {
536
+ dryRun: true,
537
+ wouldDelete: true,
538
+ calendarId,
539
+ eventId,
540
+ changes: [{
541
+ action: 'delete_event',
542
+ calendarId,
543
+ eventId,
544
+ summary: event.summary ?? null,
545
+ start: event.start ?? null,
546
+ end: event.end ?? null,
547
+ }],
548
+ };
549
+ }
445
550
  const { dataHost, calendarsPath } = await discover();
446
551
  const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
447
552
 
@@ -454,6 +559,526 @@ export async function deleteEvent(calendarId, eventId) {
454
559
  return { deleted: true, eventId, calendarId };
455
560
  }
456
561
 
562
+ // ─── Bulk operations ──────────────────────────────────────────────────────────
563
+
564
+ export async function bulkUpdateEvents(calendarId, updates, dryRun = false) {
565
+ if (dryRun) {
566
+ const changes = [];
567
+ const missing = [];
568
+ for (let i = 0; i < updates.length; i++) {
569
+ const { eventId, ...fields } = updates[i];
570
+ try {
571
+ const current = await getEvent(calendarId, eventId);
572
+ changes.push({
573
+ action: 'update_event',
574
+ index: i,
575
+ calendarId,
576
+ eventId,
577
+ summary: current.summary ?? null,
578
+ fields,
579
+ });
580
+ } catch (err) {
581
+ missing.push({ index: i, eventId, error: err.message });
582
+ }
583
+ }
584
+ return {
585
+ dryRun: true,
586
+ wouldUpdate: changes.length,
587
+ calendarId,
588
+ changes,
589
+ ...(missing.length > 0 ? { missing } : {}),
590
+ };
591
+ }
592
+ const { dataHost, calendarsPath } = await discover();
593
+ let updated = 0;
594
+ const failed = [];
595
+
596
+ for (let i = 0; i < updates.length; i++) {
597
+ const { eventId, ...fields } = updates[i];
598
+ try {
599
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
600
+ const existing = await davRequest('GET', url);
601
+ if (existing.status === 404) {
602
+ failed.push({ index: i, eventId, error: 'not found' });
603
+ continue;
604
+ }
605
+ const current = parseVEvent(existing.body);
606
+ const merged = { ...current, ...fields };
607
+ const { ical } = serializeVEvent(merged, eventId);
608
+ const resp = await davRequest('PUT', url, {
609
+ contentType: 'text/calendar; charset=utf-8',
610
+ etag: existing.etag,
611
+ body: ical,
612
+ });
613
+ if (resp.status !== 204 && resp.status !== 200) {
614
+ failed.push({ index: i, eventId, error: `HTTP ${resp.status}` });
615
+ } else {
616
+ updated++;
617
+ }
618
+ } catch (err) {
619
+ failed.push({ index: i, eventId, error: err.message });
620
+ }
621
+ }
622
+
623
+ return { updated, failed: failed.length, total: updates.length, calendarId, ...(failed.length > 0 ? { errors: failed } : {}) };
624
+ }
625
+
626
+ export async function listEventsMulti(calendarIds, since = null, before = null, limit = 50) {
627
+ const results = {};
628
+ let totalCount = 0;
629
+
630
+ for (const calendarId of calendarIds) {
631
+ try {
632
+ const data = await listEvents(calendarId, since, before, limit);
633
+ results[calendarId] = data.events;
634
+ totalCount += data.count;
635
+ } catch (err) {
636
+ results[calendarId] = { error: err.message };
637
+ }
638
+ }
639
+
640
+ return { calendars: results, totalCount };
641
+ }
642
+
643
+ export async function bulkCreateEvents(calendarId, events, dryRun = false) {
644
+ if (dryRun) {
645
+ return {
646
+ dryRun: true,
647
+ wouldCreate: events.length,
648
+ calendarId,
649
+ changes: events.map((event, index) => ({
650
+ action: 'create_event',
651
+ index,
652
+ calendarId,
653
+ summary: event.summary,
654
+ start: event.start,
655
+ end: event.end ?? null,
656
+ allDay: !!event.allDay,
657
+ })),
658
+ };
659
+ }
660
+ const { dataHost, calendarsPath } = await discover();
661
+ let created = 0;
662
+ const failed = [];
663
+
664
+ for (let i = 0; i < events.length; i++) {
665
+ try {
666
+ const { ical, uid } = serializeVEvent(events[i]);
667
+ const url = `${dataHost}${calendarsPath}${calendarId}/${uid}.ics`;
668
+ const resp = await davRequest('PUT', url, {
669
+ contentType: 'text/calendar; charset=utf-8',
670
+ body: ical,
671
+ });
672
+ if (resp.status !== 201 && resp.status !== 204 && resp.status !== 200) {
673
+ failed.push({ index: i, summary: events[i].summary, error: `HTTP ${resp.status}` });
674
+ } else {
675
+ created++;
676
+ }
677
+ } catch (err) {
678
+ failed.push({ index: i, summary: events[i].summary, error: err.message });
679
+ }
680
+ }
681
+
682
+ return { created, failed: failed.length, total: events.length, calendarId, ...(failed.length > 0 ? { errors: failed } : {}) };
683
+ }
684
+
685
+ export async function bulkDeleteEvents(calendarId, eventIds, dryRun = false) {
686
+ if (dryRun) {
687
+ const changes = [];
688
+ const missing = [];
689
+ for (const eventId of eventIds) {
690
+ try {
691
+ const event = await getEvent(calendarId, eventId);
692
+ changes.push({
693
+ action: 'delete_event',
694
+ calendarId,
695
+ eventId,
696
+ summary: event.summary ?? null,
697
+ start: event.start ?? null,
698
+ end: event.end ?? null,
699
+ });
700
+ } catch (err) {
701
+ missing.push({ eventId, error: err.message });
702
+ }
703
+ }
704
+ return {
705
+ dryRun: true,
706
+ wouldDelete: changes.length,
707
+ calendarId,
708
+ changes,
709
+ ...(missing.length > 0 ? { missing } : {}),
710
+ };
711
+ }
712
+ const { dataHost, calendarsPath } = await discover();
713
+ let deleted = 0;
714
+ const failed = [];
715
+
716
+ for (let i = 0; i < eventIds.length; i++) {
717
+ try {
718
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventIds[i]}.ics`;
719
+ const resp = await davRequest('DELETE', url);
720
+ if (resp.status === 404) {
721
+ failed.push({ eventId: eventIds[i], error: 'not found' });
722
+ } else if (resp.status !== 204 && resp.status !== 200) {
723
+ failed.push({ eventId: eventIds[i], error: `HTTP ${resp.status}` });
724
+ } else {
725
+ deleted++;
726
+ }
727
+ } catch (err) {
728
+ failed.push({ eventId: eventIds[i], error: err.message });
729
+ }
730
+ }
731
+
732
+ return { deleted, failed: failed.length, total: eventIds.length, calendarId, ...(failed.length > 0 ? { errors: failed } : {}) };
733
+ }
734
+
735
+ export async function detectConflicts(calendarIds, since = null, before = null, minBuffer = 0) {
736
+ const sinceStr = since || new Date().toISOString().slice(0, 10);
737
+ const beforeStr = before || new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10);
738
+
739
+ // Fetch all events from all calendars
740
+ const allEvents = [];
741
+ for (const calendarId of calendarIds) {
742
+ try {
743
+ const data = await listEvents(calendarId, sinceStr, beforeStr, 200);
744
+ for (const ev of data.events) {
745
+ if (ev.allDay) continue; // skip all-day events
746
+ allEvents.push({ ...ev, calendarId });
747
+ }
748
+ } catch (err) {
749
+ // skip calendars that fail
750
+ }
751
+ }
752
+
753
+ // Group by date
754
+ const byDate = {};
755
+ for (const ev of allEvents) {
756
+ const date = ev.start.slice(0, 10);
757
+ if (!byDate[date]) byDate[date] = [];
758
+ byDate[date].push(ev);
759
+ }
760
+
761
+ const toMin = (t) => {
762
+ const [h, m] = t.split(':').map(Number);
763
+ return h * 60 + m;
764
+ };
765
+
766
+ const conflicts = [];
767
+ const tightGaps = [];
768
+
769
+ for (const [date, events] of Object.entries(byDate)) {
770
+ if (events.length < 2) continue;
771
+ // Sort by start time
772
+ events.sort((a, b) => a.start.localeCompare(b.start));
773
+
774
+ for (let i = 0; i < events.length; i++) {
775
+ for (let j = i + 1; j < events.length; j++) {
776
+ const a = events[i];
777
+ const b = events[j];
778
+ // Same calendar events aren't conflicts we care about
779
+ if (a.calendarId === b.calendarId) continue;
780
+
781
+ const aStart = toMin(a.start.slice(11, 16));
782
+ const aEnd = toMin(a.end.slice(11, 16));
783
+ const bStart = toMin(b.start.slice(11, 16));
784
+ const bEnd = toMin(b.end.slice(11, 16));
785
+
786
+ if (aStart < bEnd && bStart < aEnd) {
787
+ conflicts.push({
788
+ date,
789
+ event1: { summary: a.summary, start: a.start.slice(11, 16), end: a.end.slice(11, 16), calendarId: a.calendarId },
790
+ event2: { summary: b.summary, start: b.start.slice(11, 16), end: b.end.slice(11, 16), calendarId: b.calendarId },
791
+ });
792
+ } else if (minBuffer > 0) {
793
+ // Check gap between a.end and b.start (b starts after a)
794
+ const gap = bStart - aEnd;
795
+ if (gap > 0 && gap < minBuffer) {
796
+ tightGaps.push({
797
+ date,
798
+ gapMinutes: gap,
799
+ event1: { summary: a.summary, start: a.start.slice(11, 16), end: a.end.slice(11, 16), calendarId: a.calendarId },
800
+ event2: { summary: b.summary, start: b.start.slice(11, 16), end: b.end.slice(11, 16), calendarId: b.calendarId },
801
+ });
802
+ }
803
+ // Also check gap between b.end and a.start (a starts after b) — shouldn't happen since sorted, but safe
804
+ const gap2 = aStart - bEnd;
805
+ if (gap2 > 0 && gap2 < minBuffer) {
806
+ tightGaps.push({
807
+ date,
808
+ gapMinutes: gap2,
809
+ event1: { summary: b.summary, start: b.start.slice(11, 16), end: b.end.slice(11, 16), calendarId: b.calendarId },
810
+ event2: { summary: a.summary, start: a.start.slice(11, 16), end: a.end.slice(11, 16), calendarId: a.calendarId },
811
+ });
812
+ }
813
+ }
814
+ }
815
+ }
816
+ }
817
+
818
+ conflicts.sort((a, b) => a.date.localeCompare(b.date));
819
+ tightGaps.sort((a, b) => a.date.localeCompare(b.date));
820
+
821
+ return {
822
+ conflicts,
823
+ tightGaps: minBuffer > 0 ? tightGaps : undefined,
824
+ conflictCount: conflicts.length,
825
+ tightGapCount: minBuffer > 0 ? tightGaps.length : undefined,
826
+ range: { since: sinceStr, before: beforeStr },
827
+ calendarsChecked: calendarIds.length,
828
+ };
829
+ }
830
+
831
+ // ─── VTODO (Reminders) ────────────────────────────────────────────────────────
832
+
833
+ const PRIORITY_TO_ICAL = { high: '1', medium: '5', low: '9', none: '0' };
834
+ const PRIORITY_FROM_ICAL = { '1': 'high', '2': 'high', '3': 'high', '4': 'high', '5': 'medium', '6': 'low', '7': 'low', '8': 'low', '9': 'low', '0': 'none' };
835
+
836
+ function parseVTodo(ical) {
837
+ const unfolded = ical.replace(/\r?\n[ \t]/g, '');
838
+ const lines = unfolded.split(/\r?\n/);
839
+
840
+ let inTodo = false;
841
+ let subDepth = 0;
842
+ const todo = {};
843
+
844
+ for (const line of lines) {
845
+ if (line === 'BEGIN:VTODO') { inTodo = true; subDepth = 0; continue; }
846
+ if (line === 'END:VTODO') { inTodo = false; continue; }
847
+ if (!inTodo) continue;
848
+ if (line.startsWith('BEGIN:')) { subDepth++; continue; }
849
+ if (line.startsWith('END:')) { subDepth--; continue; }
850
+ if (subDepth > 0) continue;
851
+
852
+ const colonIdx = line.indexOf(':');
853
+ if (colonIdx < 0) continue;
854
+ const fullKey = line.slice(0, colonIdx);
855
+ const val = line.slice(colonIdx + 1);
856
+ const key = fullKey.split(';')[0].toUpperCase();
857
+
858
+ switch (key) {
859
+ case 'UID': todo.uid = val; break;
860
+ case 'SUMMARY': todo.title = icalUnescape(val); break;
861
+ case 'DESCRIPTION': todo.notes = icalUnescape(val); break;
862
+ case 'STATUS': todo.status = val; break;
863
+ case 'PRIORITY': todo.priority = PRIORITY_FROM_ICAL[val] || 'none'; break;
864
+ case 'DUE': {
865
+ todo.due = parseIcalDate(val, fullKey);
866
+ todo.allDay = fullKey.includes('VALUE=DATE');
867
+ const tzM = fullKey.match(/TZID=([^;:]+)/);
868
+ if (tzM) todo.timezone = tzM[1];
869
+ break;
870
+ }
871
+ case 'COMPLETED': todo.completedAt = parseIcalDate(val, fullKey); break;
872
+ case 'CREATED': todo.created = parseIcalDate(val, fullKey); break;
873
+ case 'LAST-MODIFIED': todo.lastModified = parseIcalDate(val, fullKey); break;
874
+ case 'PERCENT-COMPLETE': todo.percentComplete = parseInt(val, 10); break;
875
+ }
876
+ }
877
+
878
+ todo.completed = todo.status === 'COMPLETED';
879
+ return todo;
880
+ }
881
+
882
+ function serializeVTodo(fields, uid = null) {
883
+ const id = uid || randomUUID().toUpperCase();
884
+ const now = new Date();
885
+ const dtstamp = toIcalUtc(now);
886
+
887
+ const lines = [
888
+ 'BEGIN:VCALENDAR',
889
+ 'CALSCALE:GREGORIAN',
890
+ 'PRODID:-//icloud-mcp//EN',
891
+ 'VERSION:2.0',
892
+ 'BEGIN:VTODO',
893
+ `DTSTAMP:${dtstamp}`,
894
+ `CREATED:${dtstamp}`,
895
+ `UID:${id}`,
896
+ `SUMMARY:${icalEscape(fields.title || '(No title)')}`,
897
+ `STATUS:${fields.completed ? 'COMPLETED' : (fields.status || 'NEEDS-ACTION')}`,
898
+ ];
899
+
900
+ if (fields.notes) lines.push(`DESCRIPTION:${icalEscape(fields.notes)}`);
901
+
902
+ const priority = PRIORITY_TO_ICAL[fields.priority] || '0';
903
+ if (priority !== '0') lines.push(`PRIORITY:${priority}`);
904
+
905
+ if (fields.due) {
906
+ if (fields.allDay) {
907
+ const dateStr = fields.due.replace(/-/g, '').slice(0, 8);
908
+ lines.push(`DUE;VALUE=DATE:${dateStr}`);
909
+ } else {
910
+ const tz = fields.timezone || 'America/New_York';
911
+ const dueDate = new Date(fields.due);
912
+ if (tz === 'UTC') {
913
+ lines.push(`DUE:${toIcalUtc(dueDate)}`);
914
+ } else {
915
+ lines.push(`DUE;TZID=${tz}:${toIcalLocal(dueDate)}`);
916
+ }
917
+ }
918
+ }
919
+
920
+ if (fields.completed || fields.status === 'COMPLETED') {
921
+ const completedAt = fields.completedAt ? new Date(fields.completedAt) : now;
922
+ lines.push(`COMPLETED:${toIcalUtc(completedAt)}`);
923
+ lines.push('PERCENT-COMPLETE:100');
924
+ }
925
+
926
+ lines.push('SEQUENCE:0', 'END:VTODO', 'END:VCALENDAR');
927
+ return { ical: lines.join('\r\n') + '\r\n', uid: id };
928
+ }
929
+
930
+ function parseReminderBlocks(xml) {
931
+ return splitResponses(xml).map(block => {
932
+ const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
933
+ const etagMatch = block.match(/<[^>:]*:?getetag[^>]*>"?([^"<]+)"?<\/[^>:]*:?getetag>/);
934
+
935
+ let icalText = null;
936
+ const cdataMatch = block.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
937
+ if (cdataMatch) {
938
+ icalText = cdataMatch[1];
939
+ } else {
940
+ const dataMatch = block.match(/<[^>:]*:?calendar-data[^>]*>([\s\S]*?)<\/[^>:]*:?calendar-data>/i);
941
+ if (dataMatch) icalText = dataMatch[1];
942
+ }
943
+
944
+ if (!hrefMatch || !icalText) return null;
945
+
946
+ const href = hrefMatch[1];
947
+ const parts = href.split('/').filter(Boolean);
948
+ const filename = parts[parts.length - 1];
949
+ const reminderId = filename.replace(/\.ics$/i, '');
950
+ const calendarId = parts[parts.length - 2] || null;
951
+
952
+ const todo = parseVTodo(icalText);
953
+ return { reminderId, calendarId, etag: etagMatch?.[1] || null, href, ...todo };
954
+ }).filter(Boolean);
955
+ }
956
+
957
+ export async function listReminderLists() {
958
+ const cals = await listCalendars();
959
+ const lists = cals.calendars.filter(c => c.supportedTypes.includes('VTODO'));
960
+ return { lists, count: lists.length };
961
+ }
962
+
963
+ export async function listReminders(calendarId = null, includeCompleted = false, limit = 50) {
964
+ const { dataHost, calendarsPath } = await discover();
965
+
966
+ if (!calendarId) {
967
+ const { lists } = await listReminderLists();
968
+ if (!lists.length) throw new Error('No Reminders lists found');
969
+ calendarId = lists[0].calendarId;
970
+ }
971
+
972
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
973
+ <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="DAV:">
974
+ <A:prop><A:getetag/><C:calendar-data/></A:prop>
975
+ <C:filter>
976
+ <C:comp-filter name="VCALENDAR">
977
+ <C:comp-filter name="VTODO"/>
978
+ </C:comp-filter>
979
+ </C:filter>
980
+ </C:calendar-query>`;
981
+
982
+ const url = `${dataHost}${calendarsPath}${calendarId}/`;
983
+ const resp = await davRequest('REPORT', url, {
984
+ depth: 1,
985
+ contentType: 'application/xml; charset=utf-8',
986
+ body,
987
+ });
988
+
989
+ if (resp.status === 403 || resp.status === 404) {
990
+ throw new Error(`Reminders list not found or access denied: ${calendarId} (${resp.status})`);
991
+ }
992
+
993
+ let reminders = parseReminderBlocks(resp.body);
994
+ if (!includeCompleted) reminders = reminders.filter(r => !r.completed);
995
+ reminders = reminders.slice(0, limit);
996
+
997
+ return { reminders, count: reminders.length, calendarId, includeCompleted };
998
+ }
999
+
1000
+ export async function getReminder(calendarId, reminderId) {
1001
+ const { dataHost, calendarsPath } = await discover();
1002
+ const url = `${dataHost}${calendarsPath}${calendarId}/${reminderId}.ics`;
1003
+ const resp = await davRequest('GET', url);
1004
+
1005
+ if (resp.status === 404) throw new Error(`Reminder not found: ${calendarId}/${reminderId}`);
1006
+ if (resp.status >= 400) throw new Error(`CalDAV GET failed: ${resp.status}`);
1007
+
1008
+ const todo = parseVTodo(resp.body);
1009
+ return { reminderId, calendarId, etag: resp.etag, ...todo };
1010
+ }
1011
+
1012
+ export async function createReminder(calendarId, fields) {
1013
+ const { dataHost, calendarsPath } = await discover();
1014
+
1015
+ if (!calendarId) {
1016
+ const { lists } = await listReminderLists();
1017
+ if (!lists.length) throw new Error('No Reminders lists found');
1018
+ calendarId = lists[0].calendarId;
1019
+ }
1020
+
1021
+ const { ical, uid } = serializeVTodo(fields);
1022
+ const reminderId = uid;
1023
+ const url = `${dataHost}${calendarsPath}${calendarId}/${reminderId}.ics`;
1024
+
1025
+ const resp = await davRequest('PUT', url, {
1026
+ contentType: 'text/calendar; charset=utf-8',
1027
+ body: ical,
1028
+ });
1029
+
1030
+ if (resp.status !== 201 && resp.status !== 204 && resp.status !== 200) {
1031
+ throw new Error(`CalDAV PUT failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
1032
+ }
1033
+
1034
+ return { created: true, reminderId, calendarId, etag: resp.etag };
1035
+ }
1036
+
1037
+ export async function updateReminder(calendarId, reminderId, fields) {
1038
+ const { dataHost, calendarsPath } = await discover();
1039
+ const url = `${dataHost}${calendarsPath}${calendarId}/${reminderId}.ics`;
1040
+
1041
+ const existing = await davRequest('GET', url);
1042
+ if (existing.status === 404) throw new Error(`Reminder not found: ${calendarId}/${reminderId}`);
1043
+
1044
+ const current = parseVTodo(existing.body);
1045
+ const merged = { ...current, ...fields };
1046
+ const { ical } = serializeVTodo(merged, reminderId);
1047
+
1048
+ const resp = await davRequest('PUT', url, {
1049
+ contentType: 'text/calendar; charset=utf-8',
1050
+ etag: existing.etag,
1051
+ body: ical,
1052
+ });
1053
+
1054
+ if (resp.status !== 204 && resp.status !== 200) {
1055
+ throw new Error(`CalDAV PUT (update) failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
1056
+ }
1057
+
1058
+ return { updated: true, reminderId, calendarId, etag: resp.etag };
1059
+ }
1060
+
1061
+ export async function completeReminder(calendarId, reminderId) {
1062
+ return updateReminder(calendarId, reminderId, {
1063
+ completed: true,
1064
+ status: 'COMPLETED',
1065
+ completedAt: new Date().toISOString(),
1066
+ });
1067
+ }
1068
+
1069
+ export async function deleteReminder(calendarId, reminderId) {
1070
+ const { dataHost, calendarsPath } = await discover();
1071
+ const url = `${dataHost}${calendarsPath}${calendarId}/${reminderId}.ics`;
1072
+
1073
+ const resp = await davRequest('DELETE', url);
1074
+ if (resp.status === 404) throw new Error(`Reminder not found: ${calendarId}/${reminderId}`);
1075
+ if (resp.status !== 204 && resp.status !== 200) {
1076
+ throw new Error(`CalDAV DELETE failed: ${resp.status}`);
1077
+ }
1078
+
1079
+ return { deleted: true, reminderId, calendarId };
1080
+ }
1081
+
457
1082
  export async function searchEvents(query, since = null, before = null) {
458
1083
  const { dataHost, calendarsPath } = await discover();
459
1084