import { DAVClient } from 'tsdav'; import ICAL from 'ical.js'; // iCloud CalDAV Server URL const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com'; export interface AppleCalendarEvent { id: string; title: string; startDate: string; endDate: string; description?: string; location?: string; url?: string; recurringEventId?: string; isRecurring?: boolean; } /** * Format Date to local YYYY-MM-DD string without timezone shift. */ function formatDateToLocalISO(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } export interface AppleCalendar { id: string; title: string; color?: string; isPrimary?: boolean; } /** * Create a configured DAV client for Apple iCloud */ const createClient = (email: string, appSpecificPassword: string) => { return new DAVClient({ serverUrl: ICLOUD_CALDAV_URL, credentials: { username: email, password: appSpecificPassword, }, authMethod: 'Basic', defaultAccountType: 'caldav', }); }; /** * Validate credentials by attempting to fetch calendars * @returns List of found calendars if successful */ export const validateCredentials = async (email: string, appSpecificPassword: string): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); const calendars = await client.fetchCalendars(); // Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections const eventCalendars = calendars.filter(cal => { const components: string[] = (cal as any).components || []; // Keep if components include VEVENT, or if components is empty/undefined // (some calendars don't advertise components). Exclude if VTODO-only. if (components.length === 0) return true; return components.includes('VEVENT'); }); const mappedCalendars = eventCalendars.map(cal => ({ id: cal.url, // Using URL as ID for CalDAV title: (cal.displayName as string) || 'Untitled Calendar', color: cal.calendarColor, isPrimary: false, })); console.log('[APPLE CALENDAR] Found calendars:', mappedCalendars.length, '(filtered from', calendars.length, 'total)'); return mappedCalendars; } catch (error) { console.error('Apple Calendar validation failed:', error); throw new Error('Invalid credentials or unable to connect to iCloud.'); } }; /** * Get user calendars (wrapper around validateCredentials for now as they do the same) */ export const getUserCalendars = validateCredentials; /** * Get upcoming events for a specified time period from a specific calendar */ export const getUpcomingEvents = async ( email: string, appSpecificPassword: string, calendarUrl: string, timeMin: string, timeMax: string ): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); const calendars = await client.fetchCalendars(); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { throw new Error(`Calendar not found: ${calendarUrl}`); } const events = await client.fetchCalendarObjects({ calendar: targetCalendar, timeRange: { start: new Date(timeMin).toISOString(), end: new Date(timeMax).toISOString(), }, }); const parsedEvents: AppleCalendarEvent[] = []; const minTime = new Date(timeMin).getTime(); const maxTime = new Date(timeMax).getTime(); events.forEach(eventObj => { const data = (eventObj as any).data; if (!data) return; try { const jcalData = ICAL.parse(data); const comp = new ICAL.Component(jcalData); const vevents = comp.getAllSubcomponents('vevent'); // Separate base events (with RRULE) from recurrence exceptions (with RECURRENCE-ID) const baseEvents: any[] = []; const exceptions: Map = new Map(); vevents.forEach((vevent: any) => { const recurrenceId = vevent.getFirstPropertyValue('recurrence-id'); if (recurrenceId) { // This is a recurrence exception - collect it const event = new ICAL.Event(vevent); const uid = event.uid; if (!exceptions.has(uid)) exceptions.set(uid, []); exceptions.get(uid)!.push(vevent); } else { baseEvents.push(vevent); } }); baseEvents.forEach((vevent: any) => { const event = new ICAL.Event(vevent); const rrule = vevent.getFirstPropertyValue('rrule'); if (rrule) { // Recurring event - expand occurrences within the time range const uid = event.uid; const exceptionVevents = exceptions.get(uid) || []; const exceptionDates = new Set(); // First, process exceptions that fall in our range const isAllDayRecurring = event.startDate.isDate === true; exceptionVevents.forEach((exVevent: any) => { const exEvent = new ICAL.Event(exVevent); const recId = exVevent.getFirstPropertyValue('recurrence-id'); if (recId) { exceptionDates.add(recId.toJSDate().toISOString()); } const exStart = exEvent.startDate.toJSDate(); const exEnd = exEvent.endDate.toJSDate(); const exIsAllDay = exEvent.startDate.isDate === true; if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) { parsedEvents.push({ id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`, title: exEvent.summary || 'Untitled Event', startDate: exIsAllDay ? formatDateToLocalISO(exStart) : exStart.toISOString(), endDate: exIsAllDay ? formatDateToLocalISO(exEnd) : exEnd.toISOString(), description: exEvent.description, location: exEvent.location, url: exVevent.getFirstPropertyValue('url')?.toString() || undefined, recurringEventId: exEvent.uid, isRecurring: true, }); } }); // Then expand the recurrence rule try { const duration = event.endDate.toJSDate().getTime() - event.startDate.toJSDate().getTime(); const iter = event.iterator(); let next; let safetyCount = 0; while ((next = iter.next()) && safetyCount < 500) { safetyCount++; const occStart = next.toJSDate(); const occEnd = new Date(occStart.getTime() + duration); // Stop if we've gone past the range if (occStart.getTime() > maxTime) break; // Skip if before range if (occEnd.getTime() < minTime) continue; // Skip if this occurrence is overridden by an exception if (exceptionDates.has(occStart.toISOString())) continue; parsedEvents.push({ id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`, title: event.summary || 'Untitled Event', startDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occStart.toISOString(), endDate: isAllDayRecurring ? formatDateToLocalISO(occEnd) : occEnd.toISOString(), description: event.description, location: event.location, url: vevent.getFirstPropertyValue('url')?.toString() || undefined, recurringEventId: event.uid, isRecurring: true, }); } } catch (expandErr: any) { console.error(`[APPLE CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr); } } else { // Simple non-recurring event const start = event.startDate.toJSDate(); const end = event.endDate.toJSDate(); const isAllDay = event.startDate.isDate === true; if (end.getTime() < minTime || start.getTime() > maxTime) return; parsedEvents.push({ id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`, title: event.summary || 'Untitled Event', startDate: isAllDay ? formatDateToLocalISO(start) : start.toISOString(), endDate: isAllDay ? formatDateToLocalISO(end) : end.toISOString(), description: event.description, location: event.location, url: vevent.getFirstPropertyValue('url')?.toString() || undefined, }); } }); } catch (parseErr: any) { console.error(`[APPLE CALENDAR] Error parsing event data for calendar ${calendarUrl}:`, parseErr); } }); return parsedEvents; } catch (error: any) { console.error(`[APPLE CALENDAR] Error fetching events for ${calendarUrl}:`, error); return []; } }; /** * Create a new event in the specified calendar */ export const createEvent = async ( email: string, appSpecificPassword: string, calendarUrl: string, eventData: { title: string; description?: string; location?: string; url?: string; recurrence?: string; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; } ): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); const calendars = await client.fetchCalendars(); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { throw new Error(`Calendar not found: ${calendarUrl}`); } // Generate iCal string const now = new Date(); const uid = crypto.randomUUID(); // Construct VCALENDAR/VEVENT manually to ensure compatibility // ical.js is great for parsing but sometimes verbose for creation // A simple template works well for basic events const dtStamp = now.toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; let dtStart = ''; let dtEnd = ''; let dtStartParam = ''; let dtEndParam = ''; if (eventData.start.date) { // All-day event dtStart = eventData.start.date.replace(/-/g, ''); dtEnd = eventData.end.date ? eventData.end.date.replace(/-/g, '') : dtStart; // Fallback dtStartParam = ';VALUE=DATE'; dtEndParam = ';VALUE=DATE'; // For all-day events, end date is exclusive, so if they are same, add 1 day // But typically UI handles this. Let's assume input is correct. } else if (eventData.start.dateTime) { // Timed event dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; dtEnd = eventData.end.dateTime ? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' : dtStart; } const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : ''; const location = eventData.location ? `LOCATION:${eventData.location.replace(/,/g, '\\,')}\r\n` : ''; const url = eventData.url ? `URL:${eventData.url}\r\n` : ''; let rruleLine = ''; if (eventData.recurrence) { const rruleMap: Record = { daily: 'RRULE:FREQ=DAILY', weekly: 'RRULE:FREQ=WEEKLY', biweekly: 'RRULE:FREQ=WEEKLY;INTERVAL=2', monthly: 'RRULE:FREQ=MONTHLY', yearly: 'RRULE:FREQ=YEARLY', }; if (rruleMap[eventData.recurrence]) { rruleLine = `${rruleMap[eventData.recurrence]}\r\n`; } } const iCalString = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//My Weekly ToDo List//EN BEGIN:VEVENT UID:${uid} DTSTAMP:${dtStamp} DTSTART${dtStartParam}:${dtStart} DTEND${dtEndParam}:${dtEnd} SUMMARY:${eventData.title} ${description}${location}${url}${rruleLine}END:VEVENT END:VCALENDAR`; console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString); const filename = `${uid}.ics`; await client.createCalendarObject({ calendar: targetCalendar, filename, iCalString }); return { id: `${uid}-${filename}`, // Composite ID to help with updates later if needed, but usually UID is enough title: eventData.title, startDate: eventData.start.dateTime || eventData.start.date || '', endDate: eventData.end.dateTime || eventData.end.date || '', description: eventData.description, location: eventData.location, url: eventData.url }; } catch (error) { console.error('[APPLE CALENDAR] Error creating event:', error); throw error; } }; /** * Update an existing event */ export const updateEvent = async ( email: string, appSpecificPassword: string, calendarUrl: string, eventId: string, // This might be composite or just UID eventData: { title?: string; description?: string; location?: string; url?: string; start?: { dateTime?: string; date?: string }; end?: { dateTime?: string; date?: string }; } ): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); let targetObject: any = null; // Try O(1) path first with new caldav:: ID format const parsed = parseCaldavId(eventId); if (parsed) { // Fetch single object directly by URL (O(1)) const objects = await client.fetchCalendarObjects({ calendar: { url: calendarUrl } as any, objectUrls: [parsed.objectUrl], }); targetObject = objects?.[0] || null; if (!targetObject) { throw new Error('Event not found on server at URL: ' + parsed.objectUrl); } } else { // Legacy fallback: O(n) scan for old-format IDs const calendars = await client.fetchCalendars(); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { throw new Error(`Calendar not found: ${calendarUrl}`); } const uid = eventId.split('-')[0]; const allObjects = await client.fetchCalendarObjects({ calendar: targetCalendar, }); targetObject = allObjects.find(obj => { if (obj.data) { return obj.data.includes(`UID:${uid}`); } return obj.url.includes(uid); }); if (!targetObject) { throw new Error('Event not found on server'); } } // Now we have the object. // Parse existing iCal to preserve other fields const jcal = ICAL.parse(targetObject.data); const comp = new ICAL.Component(jcal); const vevent = comp.getFirstSubcomponent('vevent'); if (!vevent) { throw new Error('No VEVENT found in calendar object'); } const event = new ICAL.Event(vevent); // Update fields if (eventData.title) event.summary = eventData.title; if (eventData.description) event.description = eventData.description; if (eventData.location) event.location = eventData.location; if (eventData.url !== undefined) { if (eventData.url) { vevent.updatePropertyWithValue('url', eventData.url); } else { vevent.removeProperty('url'); } } if (eventData.start) { if (eventData.start.date) { event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate.isDate = true; } else if (eventData.start.dateTime) { event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); event.startDate.isDate = false; } } if (eventData.end) { if (eventData.end.date) { event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true); event.endDate.isDate = true; } else if (eventData.end.dateTime) { event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); event.endDate.isDate = false; } } // Bump sequence event.sequence = (event.sequence || 0) + 1; if (vevent) { vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now()); } const updatedIcalString = comp.toString(); console.log('[APPLE CALENDAR] Updating event with iCal:', updatedIcalString); // tsdav types might be slightly off in the d.ts compared to usage or we need to check the actual signature // In d.ts: updateCalendarObject(params: { calendarObject: DAVCalendarObject ... }) -> Promise // But it doesn't seem to take 'data' in the d.ts signature shown earlier? // Wait, let's look at d.ts again. // updateCalendarObject: (params: { calendarObject: ..., headers?: ... }) // It DOES NOT show `data` or `etag` in the params in the d.ts signature shown earlier? // Let's check line 117 of d.ts: // updateCalendarObject: (params: { calendarObject: ... }) // This implies the data must be SET on the calendarObject before calling? // OR the d.ts is incomplete/wrong. // Let's assume we need to update the object locally then call update? // Or maybe we use the `davRequest` or `updateObject` lower level if `updateCalendarObject` limits us. // Actually, `updateObject` takes `url`, `data`, `etag`. // Let's try using `client.updateObject` directly which is more raw but allows data. // `targetObject.url` is what we need. await client.updateObject({ url: targetObject.url, data: updatedIcalString, etag: targetObject.etag } as any); // Cast to any to bypass type definition mismatch return { id: eventId, title: event.summary, startDate: event.startDate.toString(), endDate: event.endDate.toString(), description: event.description, location: event.location, url: vevent.getFirstPropertyValue('url')?.toString() || undefined }; } catch (error) { console.error('[APPLE CALENDAR] Error updating event:', error); throw error; } }; /** * Delete an event */ /** * Parse the new caldav:: ID format to extract the object URL and UID. * Format: "caldav::::[::occurrence-iso]" * Returns null for legacy format IDs. */ function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | null { if (!eventId.startsWith('caldav::')) return null; const parts = eventId.split('::'); if (parts.length >= 3) { return { objectUrl: parts[1], uid: parts[2] }; } return null; } export const deleteEvent = async ( email: string, appSpecificPassword: string, calendarUrl: string, eventId: string ): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); // Try O(1) path first with new caldav:: ID format const parsed = parseCaldavId(eventId); if (parsed) { await client.deleteObject({ url: parsed.objectUrl, etag: undefined, } as any); console.log('[APPLE CALENDAR] Event deleted via direct URL (O(1))'); return; } // Legacy fallback: O(n) scan for old-format IDs const calendars = await client.fetchCalendars(); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { throw new Error(`Calendar not found: ${calendarUrl}`); } const uid = eventId.split('-')[0]; const allObjects = await client.fetchCalendarObjects({ calendar: targetCalendar, }); const targetObject = allObjects.find(obj => { if (obj.data) { return obj.data.includes(`UID:${uid}`); } return obj.url.includes(uid); }); if (!targetObject) { console.warn('[APPLE CALENDAR] Event to delete not found, maybe already deleted?'); return; } await client.deleteObject({ url: targetObject.url, etag: targetObject.etag } as any); console.log('[APPLE CALENDAR] Event deleted via legacy scan'); } catch (error) { console.error('[APPLE CALENDAR] Error deleting event:', error); throw error; } };