602 lines
20 KiB
TypeScript
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;
|
|
}
|
|
};
|
|
|