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",
|
"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": {
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user