From f6e158af8f6f6ec03a167848a25f829e93e95f50 Mon Sep 17 00:00:00 2001 From: mARTin Date: Thu, 26 Mar 2026 09:35:25 +0100 Subject: [PATCH] fix: reduce iCloud CalDAV connections to prevent rate limiting - Apple/Synology: batch fetch events with single client login instead of N separate connections - Apple: add client cache (5min TTL) to reuse authenticated DAVClient across operations - Apple: add retry with backoff for transient ConnectTimeoutError/SSL failures - Increase cache stale threshold from 2min to 15min - Increase background sync interval from 2min to 15min - Throttle tab-focus sync to at most once per 5min v1.75.2 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/components/WeeklyView.tsx | 7 +- src/lib/apple-calendar.ts | 274 +++++++++++++++++++++++++++++++--- src/lib/calendar-cache.ts | 2 +- src/lib/calendar-events.ts | 140 +++++++---------- src/lib/synology-calendar.ts | 190 +++++++++++++++++++++++ 6 files changed, 510 insertions(+), 105 deletions(-) diff --git a/package.json b/package.json index 41a8e63..e83d8d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.75.1", + "version": "1.75.2", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 4b2a842..591ab13 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -2976,16 +2976,21 @@ export default function WeeklyView() { console.error("[SYNC] Calendar sync error:", e); } }, - 2 * 60 * 1000, + 15 * 60 * 1000, // 15 min — avoid iCloud rate limiting ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); // Sync when tab regains focus (catches external changes in other apps) + // Throttled: at most once per 5 minutes to avoid iCloud rate limiting + const lastFocusSyncRef = useRef(0); useEffect(() => { if (!session) return; const handleVisibility = () => { if (!document.hidden) { + const now = Date.now(); + if (now - lastFocusSyncRef.current < 5 * 60 * 1000) return; // throttle + lastFocusSyncRef.current = now; fetchCalendarEvents(); fetchTasks(); } diff --git a/src/lib/apple-calendar.ts b/src/lib/apple-calendar.ts index 756f4fa..a61e929 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -195,16 +195,72 @@ const createClient = (email: string, appSpecificPassword: string) => { }); }; +/** + * Client cache: reuse authenticated DAVClient instances to avoid repeated login/fetchCalendars + * calls that trigger iCloud rate limiting. Cache entries expire after 5 minutes. + */ +interface CachedClient { + client: DAVClient; + calendars: any[]; + createdAt: number; +} +const clientCache = new Map(); +const CLIENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +async function getOrCreateClient(email: string, appSpecificPassword: string): Promise<{ client: DAVClient; calendars: any[] }> { + const cacheKey = `${email}:${appSpecificPassword.slice(0, 4)}`; + const cached = clientCache.get(cacheKey); + + if (cached && (Date.now() - cached.createdAt) < CLIENT_CACHE_TTL) { + return { client: cached.client, calendars: cached.calendars }; + } + + const client = createClient(email, appSpecificPassword); + await client.login(); + const calendars = await client.fetchCalendars(); + + clientCache.set(cacheKey, { client, calendars, createdAt: Date.now() }); + return { client, calendars }; +} + +/** + * Retry wrapper for iCloud operations that may fail due to rate limiting / timeouts. + */ +async function withRetry(fn: () => Promise, maxRetries = 2, label = 'operation'): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + const isTimeout = err?.cause?.code === 'UND_ERR_CONNECT_TIMEOUT' || + err?.message?.includes('ConnectTimeout') || + err?.message?.includes('fetch failed') || + err?.message?.includes('SocketError') || + err?.message?.includes('SSL'); + + if (isTimeout && attempt < maxRetries) { + const delay = (attempt + 1) * 3000; // 3s, 6s + console.warn(`[APPLE CALENDAR] ${label} attempt ${attempt + 1} failed (timeout), retrying in ${delay}ms...`); + // Invalidate client cache on timeout — connection may be stale + clientCache.clear(); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw err; + } + } + throw new Error('Unreachable'); +} + /** * Validate credentials by attempting to fetch calendars * @returns List of found calendars if successful */ export const validateCredentials = async (email: string, appSpecificPassword: string): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); - - const calendars = await client.fetchCalendars(); + const { calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'validateCredentials' + ); // Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections const eventCalendars = calendars.filter(cal => { @@ -246,10 +302,10 @@ export const getUpcomingEvents = async ( timeMax: string ): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); - - const calendars = await client.fetchCalendars(); + const { client, calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'getUpcomingEvents' + ); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { @@ -411,6 +467,180 @@ export const getUpcomingEvents = async ( } }; +/** + * Batch fetch events from multiple calendars with a single login. + * Avoids creating a new client/login per calendar (which causes iCloud rate-limiting). + */ +export const getUpcomingEventsBatch = async ( + email: string, + appSpecificPassword: string, + calendarUrls: string[], + timeMin: string, + timeMax: string +): Promise> => { + const results = new Map(); + if (calendarUrls.length === 0) return results; + + try { + const { client, calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'getUpcomingEventsBatch' + ); + + for (const calendarUrl of calendarUrls) { + try { + const targetCalendar = calendars.find(c => c.url === calendarUrl); + if (!targetCalendar) { + console.log(`[APPLE CALENDAR] Calendar not found: ${calendarUrl}`); + results.set(calendarUrl, []); + continue; + } + + 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'); + const baseEvents: any[] = []; + const exceptions: Map = new Map(); + + vevents.forEach((vevent: any) => { + const recurrenceId = vevent.getFirstPropertyValue('recurrence-id'); + if (recurrenceId) { + 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) { + const uid = event.uid; + const exceptionVevents = exceptions.get(uid) || []; + const exceptionDates = new Set(); + const isAllDayRecurring = event.startDate.isDate === true; + + exceptionVevents.forEach((exVevent: any) => { + const exEvent = new ICAL.Event(exVevent); + const exDtStartProp = exVevent.getFirstProperty('dtstart'); + const exTzid = exDtStartProp?.getParameter('tzid') as string | undefined; + const recId = exVevent.getFirstPropertyValue('recurrence-id'); + if (recId) exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString()); + + const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid); + const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid); + 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, + ...extractExtendedProps(exVevent), + }); + } + }); + + try { + const dtStartProp = vevent.getFirstProperty('dtstart'); + const dtStartTzid = dtStartProp?.getParameter('tzid') as string | undefined; + const startUtc = icalTimeToUtcDate(event.startDate, dtStartTzid); + const endUtc = icalTimeToUtcDate(event.endDate, dtStartTzid); + const duration = endUtc.getTime() - startUtc.getTime(); + const iter = event.iterator(); + let next; + let safetyCount = 0; + + while ((next = iter.next()) && safetyCount < 500) { + safetyCount++; + const occStart = icalTimeToUtcDate(next, dtStartTzid); + const occEnd = new Date(occStart.getTime() + duration); + if (occStart.getTime() > maxTime) break; + if (occEnd.getTime() < minTime) continue; + 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, + ...extractExtendedProps(vevent), + }); + } + } catch (expandErr: any) { + console.error(`[APPLE CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr); + } + } else { + const nrDtStartProp = vevent.getFirstProperty('dtstart'); + const nrTzid = nrDtStartProp?.getParameter('tzid') as string | undefined; + const start = icalTimeToUtcDate(event.startDate, nrTzid); + const end = icalTimeToUtcDate(event.endDate, nrTzid); + 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, + ...extractExtendedProps(vevent), + }); + } + }); + } catch (parseErr: any) { + console.error(`[APPLE CALENDAR] Error parsing event data for calendar ${calendarUrl}:`, parseErr); + } + }); + + results.set(calendarUrl, parsedEvents); + console.log(`[APPLE CALENDAR] Batch: ${parsedEvents.length} events from ${calendarUrl.split('/').pop()}`); + } catch (calErr: any) { + console.error(`[APPLE CALENDAR] Batch error for ${calendarUrl}:`, calErr.message || calErr); + results.set(calendarUrl, []); + } + } + } catch (error: any) { + console.error(`[APPLE CALENDAR] Batch login/init failed:`, error.message || error); + calendarUrls.forEach(url => results.set(url, [])); + } + + return results; +}; + /** * Create a new event in the specified calendar */ @@ -439,10 +669,10 @@ export const createEvent = async ( } ): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); - - const calendars = await client.fetchCalendars(); + const { client, calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'createEvent' + ); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { @@ -629,8 +859,10 @@ export const updateEvent = async ( } ): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); + const { client, calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'updateEvent' + ); let targetObject: any = null; @@ -648,7 +880,6 @@ export const updateEvent = async ( } } 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) { @@ -880,8 +1111,10 @@ export const deleteRecurringInstance = async ( deleteMode: string ): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); + const { client } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'deleteRecurringInstance' + ); // Parse the caldav ID to get objectUrl and occurrence date const parts = eventId.split('::'); @@ -1040,8 +1273,10 @@ export const deleteEvent = async ( eventId: string ): Promise => { try { - const client = createClient(email, appSpecificPassword); - await client.login(); + const { client, calendars } = await withRetry( + () => getOrCreateClient(email, appSpecificPassword), + 2, 'deleteEvent' + ); // Try O(1) path first with new caldav:: ID format const parsed = parseCaldavId(eventId); @@ -1055,7 +1290,6 @@ export const deleteEvent = async ( } // Legacy fallback: O(n) scan for old-format IDs - const calendars = await client.fetchCalendars(); const targetCalendar = calendars.find(c => c.url === calendarUrl); if (!targetCalendar) { diff --git a/src/lib/calendar-cache.ts b/src/lib/calendar-cache.ts index e5d8b0a..772023f 100644 --- a/src/lib/calendar-cache.ts +++ b/src/lib/calendar-cache.ts @@ -4,7 +4,7 @@ import { prisma } from './prisma'; import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events'; -const STALE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes — keep in sync with external changes +const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes — avoid iCloud rate limiting /** * Get the Monday (start of ISO week) for a given date. diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index 7ae9829..e494441 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -1,7 +1,7 @@ import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar'; -import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar'; +import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents, getUpcomingEventsBatch as getAppleEventsBatch } from './apple-calendar'; import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar'; -import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar'; +import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents, getUpcomingEventsBatch as getSynologyEventsBatch } from './synology-calendar'; import { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar'; import { PrismaClient } from '@prisma/client'; @@ -397,21 +397,12 @@ export const getCalendarEvents = async ( } // Get user calendars to identify which ones to fetch events from - // We can pass null/dummy client if getUserCalendars just calls validateCredentials which creates its own client - // Actually getUserCalendars in apple-calendar.ts (wrapper around validateCredentials) takes (email, password) - // But here it was imported as getAppleCalendars(appleClient, accessToken) which matched the old signature? - - // Let's check apple-calendar.ts signature for getUserCalendars. - // It is: export const getUserCalendars = validateCredentials; - // validateCredentials: (email: string, appSpecificPassword: string) - const freshCalendars = await getAppleCalendars(email, appPassword); // Use stored selection state if available, otherwise use all fresh calendars let calendars = freshCalendars; if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) { const storedCalendars = connection.calendars as any[]; - // Only fetch from calendars that are selected calendars = freshCalendars.filter(fc => { const stored = storedCalendars.find((sc: any) => sc.id === fc.id); return stored ? stored.selected !== false : true; @@ -419,17 +410,18 @@ export const getCalendarEvents = async ( } const calendarIds = calendars.map(c => c.id); - // Fetch events for each calendar - for (const calendarId of calendarIds) { - const calendarEvents = await getAppleEvents( - email, - appPassword, - calendarId, - timeMin, - timeMax - ); + // Batch fetch: single login, single calendar list, then fetch all calendars + console.log(`[CALENDAR] Apple: batch fetching ${calendarIds.length} calendars with single connection`); + const batchResults = await getAppleEventsBatch( + email, + appPassword, + calendarIds, + timeMin, + timeMax + ); - console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`); + for (const [calendarId, calendarEvents] of batchResults) { + console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from Apple calendar ${calendarId}`); events = events.concat(calendarEvents.map((event: any) => { const isDateOnly = (s: string) => s && !s.includes('T'); @@ -519,68 +511,52 @@ export const getCalendarEvents = async ( continue; } - for (const calendarId of calendarIds) { - try { - const calendarEvents = await getSynologyEvents( - serverUrl, - username, - password, + // Batch fetch: single login, single calendar list, then fetch all calendars + console.log(`[CALENDAR] Synology: batch fetching ${calendarIds.length} calendars with single connection`); + const batchResults = await getSynologyEventsBatch( + serverUrl, + username, + password, + calendarIds, + timeMin, + timeMax + ); + + for (const [calendarId, calendarEvents] of batchResults) { + console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`); + const calendarData = calendars.find(c => c.id === calendarId); + const freshCal = freshCalendars.find(c => c.id === calendarId); + + events = events.concat(calendarEvents.map((event: any) => { + const isDateOnly = (s: string) => s && !s.includes('T'); + const startIsAllDay = isDateOnly(event.startDate); + return { + id: event.id, + title: event.title, + description: event.description, + start: { + dateTime: startIsAllDay ? undefined : event.startDate, + date: startIsAllDay ? event.startDate : undefined, + }, + end: { + dateTime: startIsAllDay ? undefined : event.endDate, + date: startIsAllDay ? event.endDate : undefined, + }, + location: event.location, + url: event.url, + recurringEventId: event.recurringEventId, + isRecurring: event.isRecurring, + source: 'synology' as const, calendarId, - timeMin, - timeMax - ); - - console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`); - const calendarData = calendars.find(c => c.id === calendarId); - const freshCal = freshCalendars.find(c => c.id === calendarId); - - events = events.concat(calendarEvents.map((event: any) => { - const isDateOnly = (s: string) => s && !s.includes('T'); - const startIsAllDay = isDateOnly(event.startDate); - return { - id: event.id, - title: event.title, - description: event.description, - start: { - dateTime: startIsAllDay ? undefined : event.startDate, - date: startIsAllDay ? event.startDate : undefined, - }, - end: { - dateTime: startIsAllDay ? undefined : event.endDate, - date: startIsAllDay ? event.endDate : undefined, - }, - location: event.location, - url: event.url, - recurringEventId: event.recurringEventId, - isRecurring: event.isRecurring, - source: 'synology' as const, - calendarId, - calendarTitle: calendarData?.title || 'Synology Calendar', - backgroundColor: calendarData?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff', - reminders: event.reminders as EventReminder[] || undefined, - busyStatus: event.busyStatus as BusyStatus || undefined, - visibility: event.visibility as EventVisibility || undefined, - attendees: event.attendees as EventAttendee[] || undefined, - attachments: event.attachments as EventAttachment[] || undefined, - }; - })); - } catch (calError: any) { - const msg = calError?.message || ''; - if (msg.includes('not found') || msg.includes('Not found') || calError?.status === 404) { - console.warn(`[CALENDAR] Synology: calendar ${calendarId} not found, removing from stored list`); - calendars = calendars.filter((c: any) => c.id !== calendarId); - try { - await prisma.calendarConnection.update({ - where: { id: connection.id }, - data: { calendars: calendars }, - }); - } catch (dbErr) { - console.error('[CALENDAR] Synology: failed to prune calendar from DB:', dbErr); - } - } else { - console.error(`[CALENDAR] Synology: error fetching events from calendar ${calendarId}:`, calError); - } - } + calendarTitle: calendarData?.title || 'Synology Calendar', + backgroundColor: calendarData?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff', + reminders: event.reminders as EventReminder[] || undefined, + busyStatus: event.busyStatus as BusyStatus || undefined, + visibility: event.visibility as EventVisibility || undefined, + attendees: event.attendees as EventAttendee[] || undefined, + attachments: event.attachments as EventAttachment[] || undefined, + }; + })); } } else if (connection.provider === 'outlook') { console.log('[CALENDAR] Processing Outlook connection:', connection.id); diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 76a4ff1..fbe9607 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -435,6 +435,196 @@ export const getUpcomingEvents = async ( } }; +/** + * Batch fetch events from multiple Synology calendars using a single client/login. + * Avoids creating N separate connections for N calendars. + */ +export const getUpcomingEventsBatch = async ( + serverUrl: string, + username: string, + password: string, + calendarUrls: string[], + timeMin: string, + timeMax: string +): Promise> => { + const results = new Map(); + if (calendarUrls.length === 0) return results; + + try { + const client = createClient(serverUrl, username, password); + await loginClient(client, serverUrl, username); + const calendars = await client.fetchCalendars(); + + const getPath = (url: string) => { + try { return new URL(url).pathname; } catch { return url; } + }; + + const minTime = new Date(timeMin).getTime(); + const maxTime = new Date(timeMax).getTime(); + + for (const calendarUrl of calendarUrls) { + try { + const targetPath = getPath(calendarUrl); + const targetCalendar = calendars.find(c => getPath(c.url) === targetPath); + if (!targetCalendar) { + console.log(`[SYNOLOGY CALENDAR] Calendar not found: ${calendarUrl}`); + results.set(calendarUrl, []); + continue; + } + + const events = await client.fetchCalendarObjects({ + calendar: targetCalendar, + timeRange: { + start: new Date(timeMin).toISOString(), + end: new Date(timeMax).toISOString(), + }, + }); + + const parsedEvents: SynologyCalendarEvent[] = []; + + 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'); + const baseEvents: any[] = []; + const exceptions: Map = new Map(); + + vevents.forEach((vevent: any) => { + const recurrenceId = vevent.getFirstPropertyValue('recurrence-id'); + if (recurrenceId) { + 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) { + const uid = event.uid; + const exceptionVevents = exceptions.get(uid) || []; + const exceptionDates = new Set(); + const isAllDayRecurring = event.startDate.isDate === true; + + exceptionVevents.forEach((exVevent: any) => { + const exEvent = new ICAL.Event(exVevent); + const exDtStartProp = exVevent.getFirstProperty('dtstart'); + const exTzid = exDtStartProp?.getParameter('tzid') as string | undefined; + const recId = exVevent.getFirstPropertyValue('recurrence-id'); + if (recId) exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString()); + + const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid); + const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid); + 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, + ...extractExtendedProps(exVevent), + }); + } + }); + + try { + const dtStartProp = vevent.getFirstProperty('dtstart'); + const dtStartTzid = dtStartProp?.getParameter('tzid') as string | undefined; + const startUtc = icalTimeToUtcDate(event.startDate, dtStartTzid); + const endUtc = icalTimeToUtcDate(event.endDate, dtStartTzid); + const duration = endUtc.getTime() - startUtc.getTime(); + const iter = event.iterator(); + let next; + let safetyCount = 0; + + while ((next = iter.next()) && safetyCount < 500) { + safetyCount++; + const occStart = icalTimeToUtcDate(next, dtStartTzid); + const occEnd = new Date(occStart.getTime() + duration); + + if (occStart.getTime() > maxTime) break; + if (occEnd.getTime() < minTime) continue; + 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(occStart) : occEnd.toISOString(), + description: event.description, + location: event.location, + url: vevent.getFirstPropertyValue('url')?.toString() || undefined, + recurringEventId: event.uid, + isRecurring: true, + ...extractExtendedProps(vevent), + }); + } + } catch (expandErr: any) { + console.error(`[SYNOLOGY CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr); + } + } else { + const nrDtStartProp = vevent.getFirstProperty('dtstart'); + const nrTzid = nrDtStartProp?.getParameter('tzid') as string | undefined; + const start = icalTimeToUtcDate(event.startDate, nrTzid); + const end = icalTimeToUtcDate(event.endDate, nrTzid); + 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, + ...extractExtendedProps(vevent), + }); + } + }); + } catch (parseErr: any) { + console.error(`[SYNOLOGY CALENDAR] Error parsing event data for calendar ${calendarUrl}:`, parseErr); + } + }); + + results.set(calendarUrl, parsedEvents); + } catch (calErr: any) { + const msg = calErr?.message || ''; + const status = calErr?.response?.status || calErr?.status; + if (status === 404 || msg.includes('not found') || msg.includes('Calendar not found')) { + console.warn(`[SYNOLOGY CALENDAR] Calendar not found in batch: ${calendarUrl}`); + results.set(calendarUrl, []); + } else { + console.error(`[SYNOLOGY CALENDAR] Batch error for ${calendarUrl}:`, calErr); + results.set(calendarUrl, []); + } + } + } + + return results; + } catch (error: any) { + console.error('[SYNOLOGY CALENDAR] Batch fetch failed:', error); + // Return empty results for all calendars + calendarUrls.forEach(url => results.set(url, [])); + return results; + } +}; + /** * Create a new event in the specified calendar */