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; } 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(); // Fetch calendars logic would go here // Since tsdav creates a complex object graph, we'll wrap this in a try/catch const calendars = await client.fetchCalendars(); const mappedCalendars = calendars.map(cal => ({ id: cal.url, // Using URL as ID for CalDAV title: (cal.displayName as string) || 'Untitled Calendar', color: cal.calendarColor, isPrimary: false, // Hard to determine primary in generic CalDAV })); console.log('[APPLE CALENDAR] Found calendars:', mappedCalendars.map(c => ({ title: c.title, color: c.color }))); 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 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(); if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) { parsedEvents.push({ id: `${exEvent.uid}-${exStart.toISOString()}`, title: exEvent.summary || 'Untitled Event', startDate: exStart.toISOString(), endDate: exEnd.toISOString(), description: exEvent.description, location: exEvent.location }); } }); // 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: `${event.uid}-${occStart.toISOString()}`, title: event.summary || 'Untitled Event', startDate: occStart.toISOString(), endDate: occEnd.toISOString(), description: event.description, location: event.location }); } } 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(); if (end.getTime() < minTime || start.getTime() > maxTime) return; parsedEvents.push({ id: event.uid || eventObj.url, title: event.summary || 'Untitled Event', startDate: start.toISOString(), endDate: end.toISOString(), description: event.description, location: event.location }); } }); } 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; 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 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}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 }; } 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; 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}`); } // Parse IDs - implementation specific // Our getUpcomingEvents returns ID as "UID-filename" or just UID if filename not avail? // Actually getUpcomingEvents returns `${event.uid}-${occStart}` for recurring // or `event.uid || eventObj.url` for simple. // We need the original object URL (filename) to update via DAV. // If we only have UID, we have to search for it. // Strategy: Fetch all objects in range (expensive?) or try to find by UID? // CALDAV allows query by UID. // Let's assume eventId passed in is the UID for now, or we can extract it. const uid = eventId.split('-')[0]; // Simple heuristic // Use calendarQuery to find the object by UID // Valid PROP query for getetag and calendar-data // Tsdav doesn't expose a simple "findOneByUID". // We'll traverse, assuming we can filter. // Actually, `fetchCalendarObjects` allows filters. /* NOTE: tsdav filter support is XML based. Constructing a filter for UID: ${uid} */ // Since constructing that XML object via tsdav's types might be complex, // let's try a simpler approach if possible, or build the object. // For now, let's assume we can fetch objects and find the match in memory if the range isn't too huge? // No, better to search. // Let's try to pass a simpler time range around the event if we knew the time. // If not, we scan. // Given we are editing, we usually have the original time. // But `eventData` only has NEW data. We might need valid old data. // Let's rely on client logic to pass us enough info? // Wait, `updateCalendarEvent` in `calendar-events.ts` calls us. // Let's assume for MVP we fetch objects in a wide range? No. // Let's use `fetchCalendarObjects` without timerange -> fetches all? Dangerous for large cals. // Alternative: The `eventId` from our `getUpcomingEvents` was `event.uid` (or derived). // Let's try to match by UID. // Workaround: We will use a time range if provided in inputs (unlikely for existing?) // Actually, we don't have the OLD time in the `updateEvent` signature here easily unless we fetch. // Let's try to standard approach: Fetch all from now - 1 month to + 1 year? // Or just valid `calendar-query` with UID filter. // Since constructing the filter manually is hard in this context without xml-js helpers handy... // I will try to fetch the object by its URL if the ID *was* the URL. // In `getUpcomingEvents`, for simple events, we returned `id: event.uid || eventObj.url`. // If it's a URL (ends in .ics), we can just use it. let objectUrl = ''; let etag = ''; let existingIcal = ''; if (eventId.endsWith('.ics')) { // It looks like a filename/url objectUrl = eventId; // But we need the full URL or relative? // tsdav expects `calendarObject.url`. } // If we can't easily find it by ID, we might fail. // Let's assume for this iteration we try to find it. const allObjects = await client.fetchCalendarObjects({ calendar: targetCalendar, // No time range = all? limit? // Let's check if we can filter by UID in filter object }); // This fetches ALL objects (headers only usually?). // `fetchCalendarObjects` does report usually. // Find matching UID const targetObject = allObjects.find(obj => { // obj.data contains iCal string if expanded? // If not expanded, we might need to fetch data. // By default `fetchCalendarObjects` usually fetches props specified. if (obj.data) { return obj.data.includes(`UID:${uid}`); } return obj.url.includes(uid); // Fallback assumption }); 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.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 }; } catch (error) { console.error('[APPLE CALENDAR] Error updating event:', error); throw error; } }; /** * Delete an event */ export const deleteEvent = async ( email: string, appSpecificPassword: string, calendarUrl: string, eventId: 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 uid = eventId.split('-')[0]; // Find object - similar logic to update // Optimal: Pass the object URL in the ID in getUpcomingEvents to allow O(1) delete/update 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; } // Same issue as update - use deleteObject directly await client.deleteObject({ url: targetObject.url, etag: targetObject.etag } as any); // Cast to any to bypass type definition mismatch console.log('[APPLE CALENDAR] Event deleted successfully'); } catch (error) { console.error('[APPLE CALENDAR] Error deleting event:', error); throw error; } };