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

591 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;
}
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 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 = '';
let etag = '';
let existingIcal = '';
if (eventId.endsWith('.ics')) {
// It looks like a filename/url
objectUrl = eventId;
// But we need the full URL or relative?
// tsdav expects `calendarObject.url`.
}
// If we can't easily find it by ID, we might fail.
// Let's assume for this iteration we try to find it.
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
// No time range = all? limit?
// Let's check if we can filter by UID in filter object
});
// This fetches ALL objects (headers only usually?).
// `fetchCalendarObjects` does report usually.
// Find matching UID
const targetObject = allObjects.find(obj => {
// obj.data contains iCal string if expanded?
// If not expanded, we might need to fetch data.
// By default `fetchCalendarObjects` usually fetches props specified.
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid); // Fallback assumption
});
if (!targetObject) {
throw new Error('Event not found on server');
}
// Now we have the object.
// Parse existing iCal to preserve other fields
const jcal = ICAL.parse(targetObject.data);
const comp = new ICAL.Component(jcal);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) {
throw new Error('No VEVENT found in calendar object');
}
const event = new ICAL.Event(vevent);
// Update fields
if (eventData.title) event.summary = eventData.title;
if (eventData.description) event.description = eventData.description;
if (eventData.location) event.location = eventData.location;
if (eventData.start) {
if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
event.startDate.isDate = true;
} else if (eventData.start.dateTime) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
event.startDate.isDate = false;
}
}
if (eventData.end) {
if (eventData.end.date) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
event.endDate.isDate = true;
} else if (eventData.end.dateTime) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
event.endDate.isDate = false;
}
}
// Bump sequence
event.sequence = (event.sequence || 0) + 1;
if (vevent) {
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
}
const updatedIcalString = comp.toString();
console.log('[APPLE CALENDAR] Updating event with iCal:', updatedIcalString);
// tsdav types might be slightly off in the d.ts compared to usage or we need to check the actual signature
// In d.ts: updateCalendarObject(params: { calendarObject: DAVCalendarObject ... }) -> Promise<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;
}
};