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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-26 09:35:25 +01:00
parent 62848255d7
commit f6e158af8f
6 changed files with 510 additions and 105 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -2976,16 +2976,21 @@ export default function WeeklyView() {
console.error("[SYNC] Calendar sync error:", e); console.error("[SYNC] Calendar sync error:", e);
} }
}, },
2 * 60 * 1000, 15 * 60 * 1000, // 15 min — avoid iCloud rate limiting
); );
return () => clearInterval(interval); return () => clearInterval(interval);
}, [session, fetchCalendarEvents]); }, [session, fetchCalendarEvents]);
// Sync when tab regains focus (catches external changes in other apps) // 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(() => { useEffect(() => {
if (!session) return; if (!session) return;
const handleVisibility = () => { const handleVisibility = () => {
if (!document.hidden) { if (!document.hidden) {
const now = Date.now();
if (now - lastFocusSyncRef.current < 5 * 60 * 1000) return; // throttle
lastFocusSyncRef.current = now;
fetchCalendarEvents(); fetchCalendarEvents();
fetchTasks(); fetchTasks();
} }

View File

@ -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<string, CachedClient>();
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<T>(fn: () => Promise<T>, maxRetries = 2, label = 'operation'): Promise<T> {
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 * Validate credentials by attempting to fetch calendars
* @returns List of found calendars if successful * @returns List of found calendars if successful
*/ */
export const validateCredentials = async (email: string, appSpecificPassword: string): Promise<AppleCalendar[]> => { export const validateCredentials = async (email: string, appSpecificPassword: string): Promise<AppleCalendar[]> => {
try { try {
const client = createClient(email, appSpecificPassword); const { calendars } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'validateCredentials'
const calendars = await client.fetchCalendars(); );
// Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections // Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections
const eventCalendars = calendars.filter(cal => { const eventCalendars = calendars.filter(cal => {
@ -246,10 +302,10 @@ export const getUpcomingEvents = async (
timeMax: string timeMax: string
): Promise<AppleCalendarEvent[]> => { ): Promise<AppleCalendarEvent[]> => {
try { try {
const client = createClient(email, appSpecificPassword); const { client, calendars } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'getUpcomingEvents'
const calendars = await client.fetchCalendars(); );
const targetCalendar = calendars.find(c => c.url === calendarUrl); const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) { 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<Map<string, AppleCalendarEvent[]>> => {
const results = new Map<string, AppleCalendarEvent[]>();
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<string, any[]> = 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<string>();
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 * Create a new event in the specified calendar
*/ */
@ -439,10 +669,10 @@ export const createEvent = async (
} }
): Promise<AppleCalendarEvent> => { ): Promise<AppleCalendarEvent> => {
try { try {
const client = createClient(email, appSpecificPassword); const { client, calendars } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'createEvent'
const calendars = await client.fetchCalendars(); );
const targetCalendar = calendars.find(c => c.url === calendarUrl); const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) { if (!targetCalendar) {
@ -629,8 +859,10 @@ export const updateEvent = async (
} }
): Promise<AppleCalendarEvent> => { ): Promise<AppleCalendarEvent> => {
try { try {
const client = createClient(email, appSpecificPassword); const { client, calendars } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'updateEvent'
);
let targetObject: any = null; let targetObject: any = null;
@ -648,7 +880,6 @@ export const updateEvent = async (
} }
} else { } else {
// Legacy fallback: O(n) scan for old-format IDs // Legacy fallback: O(n) scan for old-format IDs
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl); const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) { if (!targetCalendar) {
@ -880,8 +1111,10 @@ export const deleteRecurringInstance = async (
deleteMode: string deleteMode: string
): Promise<void> => { ): Promise<void> => {
try { try {
const client = createClient(email, appSpecificPassword); const { client } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'deleteRecurringInstance'
);
// Parse the caldav ID to get objectUrl and occurrence date // Parse the caldav ID to get objectUrl and occurrence date
const parts = eventId.split('::'); const parts = eventId.split('::');
@ -1040,8 +1273,10 @@ export const deleteEvent = async (
eventId: string eventId: string
): Promise<void> => { ): Promise<void> => {
try { try {
const client = createClient(email, appSpecificPassword); const { client, calendars } = await withRetry(
await client.login(); () => getOrCreateClient(email, appSpecificPassword),
2, 'deleteEvent'
);
// Try O(1) path first with new caldav:: ID format // Try O(1) path first with new caldav:: ID format
const parsed = parseCaldavId(eventId); const parsed = parseCaldavId(eventId);
@ -1055,7 +1290,6 @@ export const deleteEvent = async (
} }
// Legacy fallback: O(n) scan for old-format IDs // Legacy fallback: O(n) scan for old-format IDs
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl); const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) { if (!targetCalendar) {

View File

@ -4,7 +4,7 @@
import { prisma } from './prisma'; import { prisma } from './prisma';
import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events'; 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. * Get the Monday (start of ISO week) for a given date.

View File

@ -1,7 +1,7 @@
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar'; 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 { 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 { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@ -397,21 +397,12 @@ export const getCalendarEvents = async (
} }
// Get user calendars to identify which ones to fetch events from // 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); const freshCalendars = await getAppleCalendars(email, appPassword);
// Use stored selection state if available, otherwise use all fresh calendars // Use stored selection state if available, otherwise use all fresh calendars
let calendars = freshCalendars; let calendars = freshCalendars;
if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) { if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) {
const storedCalendars = connection.calendars as any[]; const storedCalendars = connection.calendars as any[];
// Only fetch from calendars that are selected
calendars = freshCalendars.filter(fc => { calendars = freshCalendars.filter(fc => {
const stored = storedCalendars.find((sc: any) => sc.id === fc.id); const stored = storedCalendars.find((sc: any) => sc.id === fc.id);
return stored ? stored.selected !== false : true; return stored ? stored.selected !== false : true;
@ -419,17 +410,18 @@ export const getCalendarEvents = async (
} }
const calendarIds = calendars.map(c => c.id); const calendarIds = calendars.map(c => c.id);
// Fetch events for each calendar // Batch fetch: single login, single calendar list, then fetch all calendars
for (const calendarId of calendarIds) { console.log(`[CALENDAR] Apple: batch fetching ${calendarIds.length} calendars with single connection`);
const calendarEvents = await getAppleEvents( const batchResults = await getAppleEventsBatch(
email, email,
appPassword, appPassword,
calendarId, calendarIds,
timeMin, timeMin,
timeMax 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) => { events = events.concat(calendarEvents.map((event: any) => {
const isDateOnly = (s: string) => s && !s.includes('T'); const isDateOnly = (s: string) => s && !s.includes('T');
@ -519,68 +511,52 @@ export const getCalendarEvents = async (
continue; continue;
} }
for (const calendarId of calendarIds) { // Batch fetch: single login, single calendar list, then fetch all calendars
try { console.log(`[CALENDAR] Synology: batch fetching ${calendarIds.length} calendars with single connection`);
const calendarEvents = await getSynologyEvents( const batchResults = await getSynologyEventsBatch(
serverUrl, serverUrl,
username, username,
password, 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, calendarId,
timeMin, calendarTitle: calendarData?.title || 'Synology Calendar',
timeMax backgroundColor: calendarData?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff',
); reminders: event.reminders as EventReminder[] || undefined,
busyStatus: event.busyStatus as BusyStatus || undefined,
console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`); visibility: event.visibility as EventVisibility || undefined,
const calendarData = calendars.find(c => c.id === calendarId); attendees: event.attendees as EventAttendee[] || undefined,
const freshCal = freshCalendars.find(c => c.id === calendarId); attachments: event.attachments as EventAttachment[] || undefined,
};
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);
}
}
} }
} else if (connection.provider === 'outlook') { } else if (connection.provider === 'outlook') {
console.log('[CALENDAR] Processing Outlook connection:', connection.id); console.log('[CALENDAR] Processing Outlook connection:', connection.id);

View File

@ -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<Map<string, SynologyCalendarEvent[]>> => {
const results = new Map<string, SynologyCalendarEvent[]>();
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<string, any[]> = 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<string>();
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 * Create a new event in the specified calendar
*/ */