- 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>
942 lines
32 KiB
TypeScript
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;
|
|
}
|
|
}; |