My-Weekly-ToDo-List/src/lib/apple-calendar.ts
mARTin 798bcfe44a feat: Add Google Tasks, Apple Reminders, task import/sync, holidays, and UI enhancements
- Add Google Tasks integration and Apple Reminders support
- Add task import/export with list management APIs
- Add goal API for weekly goals
- Add German holidays library
- Add ImportListModal component
- Enhance WeeklyView with major UI improvements
- Enhance CalendarSettings with new connection options
- Add external task fields to database schema
- Add Playwright test suites for auth and tasks
- Add iCloud reminders Python scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:56:14 +01:00

942 lines
32 KiB
TypeScript

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<AppleCalendar[]> => {
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<AppleCalendar[]> => {
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<string>(
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 =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:ical="http://apple.com/ns/ical/">` +
`<D:prop><D:displayname/><D:resourcetype/><C:supported-calendar-component-set/><ical:calendar-color/></D:prop>` +
`</D:propfind>`;
/**
* 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<AppleCalendar[]> => {
// 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<AppleCalendarEvent[]> => {
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<string, any[]> = 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<string>();
// 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<AppleCalendarEvent> => {
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<AppleCalendarEvent> => {
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:
<filter>
<comp-filter name="VCALENDAR">
<comp-filter name="VEVENT">
<prop-filter name="UID">
<text-match collation="i;octet">${uid}</text-match>
</prop-filter>
</comp-filter>
</comp-filter>
</filter>
*/
// 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<Response>
// 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<void> => {
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<AppleCalendarEvent[]> => {
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<void> => {
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;
}
};