My-Weekly-ToDo-List/src/lib/apple-calendar.ts

602 lines
20 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;
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<AppleCalendar[]> => {
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<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
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<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 url = eventData.url ? `URL:${eventData.url}\r\n` : '';
let rruleLine = '';
if (eventData.recurrence) {
const rruleMap: Record<string, string> = {
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<AppleCalendarEvent> => {
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<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,
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::<objectUrl>::<uid>[::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<void> => {
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;
}
};