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 Apple Reminder lists (VTODO calendars) via raw PROPFIND. * * tsdav's fetchCalendars() silently drops any collection that lacks a recognised * `supported-calendar-component-set` — which is exactly what iCloud returns for * Reminders app lists. We bypass that filter by doing the PROPFIND ourselves * through davRequest(), which handles auth automatically but does no result filtering. */ export const getAppleReminderLists = async (email: string, appSpecificPassword: string): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); const account = (client as any).account; const homeUrl: string = account?.homeUrl; console.log('[APPLE REMINDERS] Calendar home URL:', homeUrl); if (!homeUrl) { throw new Error('Unable to determine calendar home URL from account discovery'); } // Collect URLs of known VEVENT calendars so we can exclude them below. const veventCalendars = await client.fetchCalendars(); const veventUrls = new Set( veventCalendars.map((c: any) => { const u: string = c.url ?? ''; return u.endsWith('/') ? u : u + '/'; }) ); console.log('[APPLE REMINDERS] Known VEVENT calendars:', veventCalendars.length); // Normalise a href to a trailing-slash absolute URL for reliable set lookups. const normalizeUrl = (href: string, base: string): string => { try { const abs = new URL(href, base).href; return abs.endsWith('/') ? abs : abs + '/'; } catch { return href.endsWith('/') ? href : href + '/'; } }; const propfindXml = `` + `` + `` + ``; /** * Issue a Depth:1 PROPFIND on `targetUrl` and return reminder-list entries. * We include a collection when: * - resourcetype contains "calendar" * - it is NOT a known VEVENT calendar (already found by fetchCalendars) * - either no supported-calendar-component-set is advertised (iCloud Reminders * often omits this), OR the set explicitly lists VTODO */ const doRawPropfind = async (targetUrl: string): Promise => { // convertIncoming: false → send raw XML string as-is (don't try to js2xml it) // parseOutgoing: true → parse the XML multistatus response into DAVResponse[] const rawResponses: any[] = await (client as any).davRequest({ url: targetUrl, init: { method: 'PROPFIND', headers: { 'Content-Type': 'application/xml; charset=UTF-8', 'Depth': '1', }, body: propfindXml, }, convertIncoming: false, parseOutgoing: true, }); console.log('[APPLE REMINDERS] PROPFIND returned', rawResponses?.length, 'entries from', targetUrl); if (rawResponses?.length > 0) { // Log first entry to understand the actual property structure. console.log('[APPLE REMINDERS] Entry[0] sample:', JSON.stringify(rawResponses[0], null, 2)); } const results: AppleCalendar[] = []; const normalTarget = normalizeUrl(targetUrl, targetUrl); for (const r of rawResponses || []) { const props = r.props ?? {}; const href: string = r.href ?? ''; // Must be a calendar collection. const resourceType = props.resourcetype ?? {}; if (!('calendar' in resourceType)) continue; const normalUrl = normalizeUrl(href, targetUrl); // Skip the collection root itself. if (normalUrl === normalTarget) continue; // Skip calendars already known as VEVENT collections. if (veventUrls.has(normalUrl)) { console.log('[APPLE REMINDERS] Skipping known VEVENT:', normalUrl); continue; } // Check supported-calendar-component-set (may be missing for iCloud Reminders). const compSet = props.supportedCalendarComponentSet; if (compSet) { const compRaw = compSet.comp; const compArray: any[] = Array.isArray(compRaw) ? compRaw : (compRaw ? [compRaw] : []); const comps: string[] = compArray .map((c: any) => c?._attributes?.name ?? c?.name ?? String(c)) .filter(Boolean); if (comps.length > 0 && !comps.includes('VTODO')) { console.log('[APPLE REMINDERS] Skipping non-VTODO collection:', href, 'comps:', comps); continue; } } // Extract display name — tsdav XML parsing may use _cdata, _text, or a plain string. const rawName = props.displayname; const displayName: string = ( typeof rawName === 'object' ? (rawName?._cdata ?? rawName?._text ?? rawName?._ ?? '') : (rawName ?? '') ).toString().trim(); if (!displayName) { console.log('[APPLE REMINDERS] Skipping unnamed collection:', href); continue; } // Extract colour (optional). const rawColor = props.calendarColor ?? props['calendar-color']; const color: string | undefined = rawColor ? (typeof rawColor === 'object' ? (rawColor?._cdata ?? rawColor?._text ?? rawColor?._ ?? '') : rawColor ).toString() || undefined : undefined; const absoluteUrl = new URL(href, targetUrl).href; console.log(`[APPLE REMINDERS] Found list: "${displayName}" → ${absoluteUrl}`); results.push({ id: absoluteUrl, title: displayName, color, isPrimary: false }); } return results; }; // Derive the /reminders/ home URL. // Apple iCloud keeps VTODO reminder collections at a /reminders/ sibling next to /calendars/. // Prefer deriving this from an actual known calendar URL (e.g. .../calendars/Home/ → .../reminders/) // rather than the homeUrl itself, since homeUrl ends in /calendars/ only on some accounts. const firstCalUrl: string = veventCalendars[0]?.url ?? homeUrl; const remindersUrl = firstCalUrl.replace(/\/calendars\/.*$/, '/reminders/'); const remindersUrlDiffers = remindersUrl !== firstCalUrl && remindersUrl !== homeUrl; // Search 1: /reminders/ path (primary — this is where Apple puts VTODO collections). let reminderLists: AppleCalendar[] = []; if (remindersUrlDiffers) { console.log('[APPLE REMINDERS] Trying /reminders/ path:', remindersUrl); try { reminderLists = await doRawPropfind(remindersUrl); } catch (e) { console.warn('[APPLE REMINDERS] /reminders/ PROPFIND failed:', e); } } // Search 2: calendar-home-set URL (catches accounts where VTODO is co-located with VEVENT). if (reminderLists.length === 0) { console.log('[APPLE REMINDERS] Trying calendar homeUrl:', homeUrl); try { const fromHome = await doRawPropfind(homeUrl); reminderLists = fromHome; } catch (e) { console.warn('[APPLE REMINDERS] homeUrl PROPFIND failed:', e); } } console.log(`[APPLE REMINDERS] Total reminder lists found: ${reminderLists.length}`); return reminderLists; } catch (error) { console.error('Apple Reminders fetch failed:', error); throw new Error('Unable to fetch Apple Reminder lists.'); } }; /** * 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 = ''; const etag = ''; const 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; } }; /** * Fetch VTODO tasks from a specific calendar */ export const fetchTasks = async ( email: string, appSpecificPassword: string, calendarUrl: string ): Promise => { try { const client = createClient(email, appSpecificPassword); await client.login(); // Use the URL directly without re-discovery — needed for /reminders/ path // which is not in the calendar-home-set returned by fetchCalendars(). console.log('[APPLE TASKS] Fetching objects from:', calendarUrl); const targetCalendar = { url: calendarUrl } as any; // Explicitly request VTODO components — tsdav defaults to VEVENT which returns nothing from reminder lists const objects = await client.fetchCalendarObjects({ calendar: targetCalendar, filters: [ { 'comp-filter': { _attributes: { name: 'VCALENDAR' }, 'comp-filter': { _attributes: { name: 'VTODO' }, }, }, }, ] as any, }); console.log(`[APPLE TASKS] Fetched ${objects.length} objects from calendar`); const parsedTasks: AppleCalendarEvent[] = []; objects.forEach(obj => { if (!obj.data) return; try { const jcal = ICAL.parse(obj.data); const comp = new ICAL.Component(jcal); const vtodo = comp.getFirstSubcomponent('vtodo'); if (vtodo) { const todo = new ICAL.Event(vtodo); const status = vtodo.getFirstPropertyValue('status'); const completed = status === 'COMPLETED' || status === 'CANCELLED'; if (!completed) { // Use helper to safely get due date if available // ICAL.js Event wrapper usually handles start/end for VEVENT. // For VTODO, 'due' is the end property eq. // safely access property let dueDate = null; const dueProp = vtodo.getFirstProperty('due'); if (dueProp) { dueDate = dueProp.getFirstValue(); } parsedTasks.push({ id: todo.uid || obj.url, title: todo.summary || 'Untitled Task', startDate: todo.startDate ? todo.startDate.toJSDate().toISOString() : '', endDate: (dueDate && (dueDate as any).toJSDate) ? (dueDate as any).toJSDate().toISOString() : '', description: todo.description || '', location: todo.location || '' }); } } } catch (e) { console.error('[APPLE TASKS] Error parsing object:', e); } }); console.log(`[APPLE TASKS] Parsed ${parsedTasks.length} tasks (excluding completed)`); return parsedTasks; } catch (error) { console.error('[APPLE TASKS] Error fetching tasks:', error); return []; } }; /** * Update a VTODO task status */ export const updateTaskStatus = async ( email: string, appSpecificPassword: string, calendarUrl: string, taskId: string, completed: boolean ): 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}`); } // Task ID might be UID or URL. Try to find the object. const uid = taskId.split('-')[0]; // Simple heuristic if we composite ID 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) { throw new Error('Task not found on server'); } const jcal = ICAL.parse(targetObject.data); const comp = new ICAL.Component(jcal); const vtodo = comp.getFirstSubcomponent('vtodo'); if (!vtodo) { throw new Error('No VTODO found in calendar object'); } const todo = new ICAL.Event(vtodo); // Update status if (completed) { vtodo.updatePropertyWithValue('status', 'COMPLETED'); // Set completed date because Apple Reminders needs it to consider it done // VTODO standard says COMPLETED property (date-time) vtodo.updatePropertyWithValue('completed', ICAL.Time.now()); vtodo.updatePropertyWithValue('percent-complete', 100); } else { vtodo.updatePropertyWithValue('status', 'NEEDS-ACTION'); vtodo.removeProperty('completed'); vtodo.updatePropertyWithValue('percent-complete', 0); } // Bump sequence if (todo.sequence !== null && todo.sequence !== undefined) { vtodo.updatePropertyWithValue('sequence', todo.sequence + 1); } vtodo.updatePropertyWithValue('dtstamp', ICAL.Time.now()); const updatedIcalString = comp.toString(); console.log('[APPLE CALENDAR] Updating active task status:', completed ? 'COMPLETED' : 'NEEDS-ACTION'); await client.updateObject({ url: targetObject.url, data: updatedIcalString, etag: targetObject.etag } as any); } catch (error) { console.error('[APPLE TASKS] Error updating task status:', error); throw error; } };