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:
parent
62848255d7
commit
f6e158af8f
@ -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": {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
* @returns List of found calendars if successful
|
||||
*/
|
||||
export const validateCredentials = async (email: string, appSpecificPassword: string): Promise<AppleCalendar[]> => {
|
||||
try {
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
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<AppleCalendarEvent[]> => {
|
||||
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<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
|
||||
*/
|
||||
@ -439,10 +669,10 @@ export const createEvent = async (
|
||||
}
|
||||
): Promise<AppleCalendarEvent> => {
|
||||
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<AppleCalendarEvent> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
// 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,
|
||||
calendarId,
|
||||
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,17 +511,18 @@ export const getCalendarEvents = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const calendarId of calendarIds) {
|
||||
try {
|
||||
const calendarEvents = await getSynologyEvents(
|
||||
// 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,
|
||||
calendarId,
|
||||
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);
|
||||
@ -564,23 +557,6 @@ export const getCalendarEvents = async (
|
||||
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') {
|
||||
console.log('[CALENDAR] Processing Outlook connection:', connection.id);
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user