- Add icalTimeToUtcDate() helper to Apple/Synology parsers that correctly converts ICAL.Time with unresolved TZIDs to UTC dates (fixes +1h shift on Synology/Apple recurring events across DST) - Add recurring icon (Repeat) to bottom-right of all calendar event blocks when event.isRecurring is true v1.63.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1038 lines
38 KiB
TypeScript
1038 lines
38 KiB
TypeScript
import { DAVClient } from 'tsdav';
|
|
import ICAL from 'ical.js';
|
|
|
|
/**
|
|
* Convert an ICAL.Time to a proper UTC JS Date.
|
|
* When ICAL.js doesn't have VTIMEZONE info, toJSDate() may treat
|
|
* TZID'd times as UTC. This helper detects that case and corrects it
|
|
* using Intl.DateTimeFormat to find the real UTC offset.
|
|
*/
|
|
function icalTimeToUtcDate(icalTime: any, tzid?: string): Date {
|
|
const jsDate = icalTime.toJSDate();
|
|
// If no TZID, or it's already UTC, or the zone is properly resolved, just return
|
|
if (!tzid || tzid === 'UTC' || tzid === 'Z') return jsDate;
|
|
// Check if ICAL.js actually resolved the timezone (zone !== utcTimezone when resolved)
|
|
if (icalTime.zone && icalTime.zone !== ICAL.Timezone.utcTimezone && icalTime.zone !== ICAL.Timezone.localTimezone) {
|
|
return jsDate; // Properly resolved
|
|
}
|
|
// ICAL.js didn't resolve the TZID — manually convert local time components to UTC
|
|
// icalTime has year, month, day, hour, minute, second as local-in-TZID values
|
|
// but toJSDate() treated them as UTC. We need to find the UTC offset for this TZID.
|
|
try {
|
|
// Create a date string that we can parse in the target timezone
|
|
const localStr = `${icalTime.year}-${String(icalTime.month).padStart(2, '0')}-${String(icalTime.day).padStart(2, '0')}T${String(icalTime.hour).padStart(2, '0')}:${String(icalTime.minute).padStart(2, '0')}:${String(icalTime.second || 0).padStart(2, '0')}`;
|
|
// Find what UTC time corresponds to this local time in the given timezone
|
|
// Use a binary search approach: start with the naive UTC interpretation and adjust
|
|
const naiveUtc = new Date(localStr + 'Z');
|
|
// Get what the local time would be at naiveUtc in the target timezone
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: tzid, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
|
});
|
|
const parts = formatter.formatToParts(naiveUtc);
|
|
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
|
const actualLocal = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
|
// The difference between what we wanted and what we got is the offset
|
|
const wantedMs = naiveUtc.getTime();
|
|
const actualMs = new Date(actualLocal + 'Z').getTime();
|
|
const offsetMs = actualMs - wantedMs;
|
|
return new Date(wantedMs - offsetMs);
|
|
} catch {
|
|
return jsDate; // Fallback
|
|
}
|
|
}
|
|
|
|
export interface SynologyCalendarEvent {
|
|
id: string;
|
|
title: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
description?: string;
|
|
location?: string;
|
|
url?: string;
|
|
recurringEventId?: string;
|
|
isRecurring?: boolean;
|
|
reminders?: Array<{ method: string; minutes: number }>;
|
|
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
|
|
attachments?: Array<{ url: string; title?: string }>;
|
|
busyStatus?: string;
|
|
visibility?: string;
|
|
}
|
|
|
|
/**
|
|
* Format Date to local YYYY-MM-DD string without timezone shift.
|
|
*/
|
|
function formatDateToLocalISO(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
/**
|
|
* Convert reminder minutes to iCalendar TRIGGER duration string
|
|
*/
|
|
function reminderMinutesToDuration(minutes: number): string {
|
|
if (minutes === 0) return 'PT0S';
|
|
const prefix = '-';
|
|
if (minutes % 10080 === 0) return `${prefix}P${minutes / 10080}W`;
|
|
if (minutes % 1440 === 0) return `${prefix}P${minutes / 1440}D`;
|
|
if (minutes % 60 === 0) return `${prefix}PT${minutes / 60}H`;
|
|
return `${prefix}PT${minutes}M`;
|
|
}
|
|
|
|
/**
|
|
* Extract extended properties from a VEVENT component
|
|
*/
|
|
function extractExtendedProps(vevent: any): Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> {
|
|
const result: Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> = {};
|
|
|
|
const valarms = vevent.getAllSubcomponents('valarm');
|
|
if (valarms && valarms.length > 0) {
|
|
result.reminders = valarms.map((valarm: any) => {
|
|
const action = valarm.getFirstPropertyValue('action') || 'DISPLAY';
|
|
const trigger = valarm.getFirstProperty('trigger');
|
|
let minutes = 15;
|
|
if (trigger) {
|
|
const triggerVal = trigger.getFirstValue();
|
|
if (triggerVal && typeof triggerVal.toSeconds === 'function') {
|
|
minutes = Math.abs(Math.round(triggerVal.toSeconds() / 60));
|
|
} else if (typeof triggerVal === 'string') {
|
|
const match = triggerVal.match(/^-?PT?(\d+)([MHDS])/i);
|
|
if (match) {
|
|
const val = parseInt(match[1]);
|
|
switch (match[2].toUpperCase()) {
|
|
case 'M': minutes = val; break;
|
|
case 'H': minutes = val * 60; break;
|
|
case 'D': minutes = val * 1440; break;
|
|
case 'S': minutes = Math.round(val / 60); break;
|
|
}
|
|
}
|
|
const weekMatch = triggerVal.match(/^-?P(\d+)W/i);
|
|
if (weekMatch) minutes = parseInt(weekMatch[1]) * 10080;
|
|
const dayMatch = triggerVal.match(/^-?P(\d+)D/i);
|
|
if (dayMatch) minutes = parseInt(dayMatch[1]) * 1440;
|
|
}
|
|
}
|
|
return { method: action.toString().toLowerCase() === 'email' ? 'email' : 'display', minutes };
|
|
});
|
|
}
|
|
|
|
const attendeeProps = vevent.getAllProperties('attendee');
|
|
if (attendeeProps && attendeeProps.length > 0) {
|
|
result.attendees = attendeeProps.map((prop: any) => {
|
|
const val = prop.getFirstValue() || '';
|
|
const email = val.replace(/^mailto:/i, '');
|
|
const cn = prop.getParameter('cn');
|
|
const partstat = prop.getParameter('partstat');
|
|
const statusMap: Record<string, string> = {
|
|
'ACCEPTED': 'accepted', 'DECLINED': 'declined', 'TENTATIVE': 'tentative', 'NEEDS-ACTION': 'needsAction',
|
|
};
|
|
return { email, displayName: cn || undefined, responseStatus: statusMap[partstat?.toUpperCase()] || 'needsAction' };
|
|
});
|
|
}
|
|
|
|
const attachProps = vevent.getAllProperties('attach');
|
|
if (attachProps && attachProps.length > 0) {
|
|
result.attachments = attachProps
|
|
.map((prop: any) => {
|
|
const val = prop.getFirstValue();
|
|
if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) {
|
|
return { url: val, title: prop.getParameter('filename') || undefined };
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean) as Array<{ url: string; title?: string }>;
|
|
if (result.attachments.length === 0) delete result.attachments;
|
|
}
|
|
|
|
const transp = vevent.getFirstPropertyValue('transp');
|
|
if (transp) result.busyStatus = transp.toString().toUpperCase() === 'TRANSPARENT' ? 'free' : 'busy';
|
|
|
|
const cls = vevent.getFirstPropertyValue('class');
|
|
if (cls) {
|
|
const clsMap: Record<string, string> = { 'PUBLIC': 'public', 'PRIVATE': 'private', 'CONFIDENTIAL': 'confidential' };
|
|
result.visibility = (clsMap[cls.toString().toUpperCase()] || 'default') as any;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export interface SynologyCalendar {
|
|
id: string;
|
|
title: string;
|
|
color?: string;
|
|
isPrimary?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Create a configured DAV client for Synology Calendar
|
|
*/
|
|
const createClient = (serverUrl: string, username: string, password: string) => {
|
|
let url = serverUrl.replace(/\/$/, '');
|
|
if (!url.includes('/caldav')) {
|
|
url = `${url}/caldav/${username}`;
|
|
}
|
|
return new DAVClient({
|
|
serverUrl: url,
|
|
credentials: {
|
|
username,
|
|
password,
|
|
},
|
|
authMethod: 'Basic',
|
|
defaultAccountType: 'caldav',
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Login with fallback: Synology's CalDAV doesn't support well-known discovery,
|
|
* so if login() fails with "cannot find homeUrl", retry with the serverUrl as homeUrl.
|
|
*/
|
|
const loginClient = async (client: DAVClient, serverUrl: string, username: string) => {
|
|
try {
|
|
await client.login();
|
|
} catch (err: any) {
|
|
if (err?.message?.includes('homeUrl')) {
|
|
// Synology doesn't support .well-known discovery — derive homeUrl from serverUrl
|
|
let homeUrl = serverUrl.replace(/\/$/, '');
|
|
if (!homeUrl.includes('/caldav')) {
|
|
homeUrl = `${homeUrl}/caldav/${username}`;
|
|
}
|
|
// Strip any calendar-specific path segments (UUID), keep up to username
|
|
const match = homeUrl.match(/^(https?:\/\/[^/]+\/caldav(?:\.php)?\/[^/]+)\/?/);
|
|
if (match) homeUrl = match[1] + '/';
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Well-known discovery failed, retrying with homeUrl:', homeUrl);
|
|
(client as any).account = {
|
|
serverUrl: client.serverUrl,
|
|
accountType: 'caldav',
|
|
homeUrl,
|
|
rootUrl: homeUrl, // tsdav requires rootUrl for fetchCalendars
|
|
};
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate credentials by attempting to fetch calendars
|
|
* @returns List of found calendars if successful
|
|
*/
|
|
export const validateCredentials = async (serverUrl: string, username: string, password: string): Promise<SynologyCalendar[]> => {
|
|
try {
|
|
const client = createClient(serverUrl, username, password);
|
|
await loginClient(client, serverUrl, username);
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
|
|
// Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections
|
|
const eventCalendars = calendars.filter(cal => {
|
|
const components: string[] = (cal as any).components || [];
|
|
// Keep if components include VEVENT, or if components is empty/undefined
|
|
if (components.length === 0) return true;
|
|
return components.includes('VEVENT');
|
|
});
|
|
|
|
const mappedCalendars = eventCalendars.map(cal => ({
|
|
id: cal.url, // Using URL as ID for CalDAV
|
|
title: (cal.displayName as string) || 'Untitled Calendar',
|
|
color: cal.calendarColor,
|
|
isPrimary: false,
|
|
}));
|
|
|
|
return mappedCalendars;
|
|
} catch (error) {
|
|
console.error('Synology Calendar validation failed:', error);
|
|
throw new Error('Invalid credentials or unable to connect to Synology Server.');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get user calendars (wrapper around validateCredentials for now as they do the same)
|
|
*/
|
|
export const getUserCalendars = validateCredentials;
|
|
|
|
/**
|
|
* Get upcoming events for a specified time period from a specific calendar
|
|
*/
|
|
export const getUpcomingEvents = async (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
calendarUrl: string,
|
|
timeMin: string,
|
|
timeMax: string
|
|
): Promise<SynologyCalendarEvent[]> => {
|
|
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 targetPath = getPath(calendarUrl);
|
|
const targetCalendar = calendars.find(c => getPath(c.url) === targetPath);
|
|
|
|
if (!targetCalendar) {
|
|
throw new Error(`Calendar not found: ${calendarUrl}`);
|
|
}
|
|
|
|
const events = await client.fetchCalendarObjects({
|
|
calendar: targetCalendar,
|
|
timeRange: {
|
|
start: new Date(timeMin).toISOString(),
|
|
end: new Date(timeMax).toISOString(),
|
|
},
|
|
});
|
|
|
|
const parsedEvents: SynologyCalendarEvent[] = [];
|
|
const minTime = new Date(timeMin).getTime();
|
|
const maxTime = new Date(timeMax).getTime();
|
|
|
|
events.forEach(eventObj => {
|
|
const data = (eventObj as any).data;
|
|
if (!data) return;
|
|
|
|
try {
|
|
const jcalData = ICAL.parse(data);
|
|
const comp = new ICAL.Component(jcalData);
|
|
const vevents = comp.getAllSubcomponents('vevent');
|
|
|
|
// Separate base events (with RRULE) from recurrence exceptions (with RECURRENCE-ID)
|
|
const baseEvents: any[] = [];
|
|
const exceptions: Map<string, any[]> = new Map();
|
|
|
|
vevents.forEach((vevent: any) => {
|
|
const recurrenceId = vevent.getFirstPropertyValue('recurrence-id');
|
|
if (recurrenceId) {
|
|
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);
|
|
}
|
|
});
|
|
|
|
return parsedEvents;
|
|
} catch (error: any) {
|
|
const msg = error?.message || '';
|
|
const status = error?.response?.status || error?.status;
|
|
// Re-throw 404 so callers can prune deleted calendars
|
|
if (status === 404 || msg.includes('not found') || msg.includes('Calendar not found')) {
|
|
console.warn(`[SYNOLOGY CALENDAR] Calendar not found: ${calendarUrl}`);
|
|
throw error;
|
|
}
|
|
// Silently handle other expected failures (permission issues, method not allowed)
|
|
if (status === 405 || msg.includes('405') || msg.includes('Not Allowed')) {
|
|
console.warn(`[SYNOLOGY CALENDAR] Calendar unavailable (${status || msg.slice(0, 60)}): ${calendarUrl}`);
|
|
} else {
|
|
console.error(`[SYNOLOGY CALENDAR] Error fetching events for ${calendarUrl}:`, error);
|
|
}
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Create a new event in the specified calendar
|
|
*/
|
|
export const createEvent = async (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
calendarUrl: string,
|
|
eventData: {
|
|
title: string;
|
|
description?: string;
|
|
location?: string;
|
|
url?: string;
|
|
recurrence?: string;
|
|
recurrenceEndDate?: string;
|
|
recurrenceCount?: number;
|
|
recurrenceInterval?: number;
|
|
recurrenceDays?: number[];
|
|
timezone?: string;
|
|
start: { dateTime?: string; date?: string };
|
|
end: { dateTime?: string; date?: string };
|
|
reminders?: Array<{ method: string; minutes: number }>;
|
|
attendees?: Array<{ email: string; displayName?: string }>;
|
|
attachments?: Array<{ url: string; title?: string }>;
|
|
busyStatus?: string;
|
|
visibility?: string;
|
|
}
|
|
): Promise<SynologyCalendarEvent> => {
|
|
try {
|
|
const client = createClient(serverUrl, username, password);
|
|
await loginClient(client, serverUrl, username);
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
|
|
|
if (!targetCalendar) {
|
|
throw new Error(`Calendar not found: ${calendarUrl}`);
|
|
}
|
|
|
|
const now = new Date();
|
|
const uid = crypto.randomUUID();
|
|
|
|
const dtStamp = now.toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
|
|
|
|
let dtStart = '';
|
|
let dtEnd = '';
|
|
let dtStartParam = '';
|
|
let dtEndParam = '';
|
|
|
|
if (eventData.start.date) {
|
|
dtStart = eventData.start.date.replace(/-/g, '');
|
|
dtEnd = eventData.end.date ? eventData.end.date.replace(/-/g, '') : dtStart; // Fallback
|
|
dtStartParam = ';VALUE=DATE';
|
|
dtEndParam = ';VALUE=DATE';
|
|
} else if (eventData.start.dateTime) {
|
|
// For recurring events, use local time with TZID to avoid DST shifts
|
|
if (eventData.recurrence && eventData.timezone) {
|
|
const tz = eventData.timezone;
|
|
const toLocalIcal = (isoStr: string) => {
|
|
const d = new Date(isoStr);
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
|
}).formatToParts(d);
|
|
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
|
return `${get('year')}${get('month')}${get('day')}T${get('hour')}${get('minute')}${get('second')}`;
|
|
};
|
|
dtStart = toLocalIcal(eventData.start.dateTime);
|
|
dtEnd = eventData.end.dateTime ? toLocalIcal(eventData.end.dateTime) : dtStart;
|
|
dtStartParam = `;TZID=${tz}`;
|
|
dtEndParam = `;TZID=${tz}`;
|
|
} else {
|
|
dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
|
|
dtEnd = eventData.end.dateTime
|
|
? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'
|
|
: dtStart;
|
|
}
|
|
}
|
|
|
|
const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';
|
|
const location = eventData.location ? `LOCATION:${eventData.location.replace(/,/g, '\\,')}\r\n` : '';
|
|
const url = eventData.url ? `URL:${eventData.url}\r\n` : '';
|
|
let rruleLine = '';
|
|
if (eventData.recurrence) {
|
|
const rruleMap: Record<string, string> = {
|
|
daily: 'RRULE:FREQ=DAILY',
|
|
weekly: 'RRULE:FREQ=WEEKLY',
|
|
biweekly: 'RRULE:FREQ=WEEKLY;INTERVAL=2',
|
|
monthly: 'RRULE:FREQ=MONTHLY',
|
|
yearly: 'RRULE:FREQ=YEARLY',
|
|
};
|
|
if (rruleMap[eventData.recurrence]) {
|
|
let rrule = rruleMap[eventData.recurrence];
|
|
if (eventData.recurrenceInterval && eventData.recurrenceInterval > 1 && eventData.recurrence !== 'biweekly') {
|
|
rrule += `;INTERVAL=${eventData.recurrenceInterval}`;
|
|
}
|
|
if (eventData.recurrenceDays && eventData.recurrenceDays.length > 0 && eventData.recurrence === 'weekly') {
|
|
const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
|
rrule += `;BYDAY=${eventData.recurrenceDays.map(d => dayMap[d]).join(',')}`;
|
|
}
|
|
if (eventData.recurrenceCount && eventData.recurrenceCount > 0) {
|
|
rrule += `;COUNT=${eventData.recurrenceCount}`;
|
|
} else if (eventData.recurrenceEndDate) {
|
|
const d = new Date(eventData.recurrenceEndDate);
|
|
d.setHours(23, 59, 59);
|
|
rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
|
|
}
|
|
rruleLine = `${rrule}\r\n`;
|
|
}
|
|
}
|
|
|
|
// Generate VALARM blocks for reminders
|
|
let valarmLines = '';
|
|
if (eventData.reminders && eventData.reminders.length > 0) {
|
|
for (const reminder of eventData.reminders) {
|
|
const action = reminder.method === 'email' ? 'EMAIL' : 'DISPLAY';
|
|
const dur = reminderMinutesToDuration(reminder.minutes);
|
|
valarmLines += `BEGIN:VALARM\r\nACTION:${action}\r\nTRIGGER:${dur}\r\n`;
|
|
if (action === 'DISPLAY') valarmLines += `DESCRIPTION:Reminder\r\n`;
|
|
valarmLines += `END:VALARM\r\n`;
|
|
}
|
|
}
|
|
|
|
let attendeeLines = '';
|
|
if (eventData.attendees && eventData.attendees.length > 0) {
|
|
for (const att of eventData.attendees) {
|
|
const cn = att.displayName ? `;CN=${att.displayName}` : '';
|
|
attendeeLines += `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION${cn}:mailto:${att.email}\r\n`;
|
|
}
|
|
}
|
|
|
|
let attachLines = '';
|
|
if (eventData.attachments && eventData.attachments.length > 0) {
|
|
for (const att of eventData.attachments) {
|
|
attachLines += `ATTACH:${att.url}\r\n`;
|
|
}
|
|
}
|
|
|
|
const transpLine = eventData.busyStatus === 'free' ? 'TRANSP:TRANSPARENT\r\n' : eventData.busyStatus ? 'TRANSP:OPAQUE\r\n' : '';
|
|
let classLine = '';
|
|
if (eventData.visibility && eventData.visibility !== 'default') {
|
|
classLine = `CLASS:${eventData.visibility.toUpperCase()}\r\n`;
|
|
}
|
|
|
|
const iCalString = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//My Weekly ToDo List//EN
|
|
BEGIN:VEVENT
|
|
UID:${uid}
|
|
DTSTAMP:${dtStamp}
|
|
DTSTART${dtStartParam}:${dtStart}
|
|
DTEND${dtEndParam}:${dtEnd}
|
|
SUMMARY:${eventData.title}
|
|
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
|
|
END:VCALENDAR`;
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
|
|
|
|
const filename = `${uid}.ics`;
|
|
|
|
await client.createCalendarObject({
|
|
calendar: targetCalendar,
|
|
filename,
|
|
iCalString
|
|
});
|
|
|
|
return {
|
|
id: `${uid}-${filename}`,
|
|
title: eventData.title,
|
|
startDate: eventData.start.dateTime || eventData.start.date || '',
|
|
endDate: eventData.end.dateTime || eventData.end.date || '',
|
|
description: eventData.description,
|
|
location: eventData.location,
|
|
url: eventData.url
|
|
};
|
|
} catch (error) {
|
|
console.error('[SYNOLOGY CALENDAR] Error creating event:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Parse the new caldav:: ID format to extract the object URL and UID.
|
|
*/
|
|
function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | null {
|
|
if (!eventId.startsWith('caldav::')) return null;
|
|
const parts = eventId.split('::');
|
|
if (parts.length >= 3) {
|
|
return { objectUrl: parts[1], uid: parts[2] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Update an existing event
|
|
*/
|
|
export const updateEvent = async (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
calendarUrl: string,
|
|
eventId: string,
|
|
eventData: {
|
|
title?: string;
|
|
description?: string;
|
|
location?: string;
|
|
url?: string;
|
|
start?: { dateTime?: string; date?: string };
|
|
end?: { dateTime?: string; date?: string };
|
|
reminders?: Array<{ method: string; minutes: number }>;
|
|
attendees?: Array<{ email: string; displayName?: string }>;
|
|
attachments?: Array<{ url: string; title?: string }>;
|
|
busyStatus?: string;
|
|
visibility?: string;
|
|
}
|
|
): Promise<SynologyCalendarEvent> => {
|
|
try {
|
|
const client = createClient(serverUrl, username, password);
|
|
await loginClient(client, serverUrl, username);
|
|
|
|
let targetObject: any = null;
|
|
|
|
const parsed = parseCaldavId(eventId);
|
|
if (parsed) {
|
|
const objects = await client.fetchCalendarObjects({
|
|
calendar: { url: calendarUrl } as any,
|
|
objectUrls: [parsed.objectUrl],
|
|
});
|
|
targetObject = objects?.[0] || null;
|
|
if (!targetObject) {
|
|
throw new Error('Event not found on server at URL: ' + parsed.objectUrl);
|
|
}
|
|
} else {
|
|
const calendars = await client.fetchCalendars();
|
|
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
|
|
|
if (!targetCalendar) {
|
|
throw new Error(`Calendar not found: ${calendarUrl}`);
|
|
}
|
|
|
|
const uid = eventId.split('-')[0];
|
|
|
|
const allObjects = await client.fetchCalendarObjects({
|
|
calendar: targetCalendar,
|
|
});
|
|
|
|
targetObject = allObjects.find(obj => {
|
|
if (obj.data) {
|
|
return obj.data.includes(`UID:${uid}`);
|
|
}
|
|
return obj.url.includes(uid);
|
|
});
|
|
|
|
if (!targetObject) {
|
|
throw new Error('Event not found on server');
|
|
}
|
|
}
|
|
|
|
const jcal = ICAL.parse(targetObject.data);
|
|
const comp = new ICAL.Component(jcal);
|
|
const vevent = comp.getFirstSubcomponent('vevent');
|
|
|
|
if (!vevent) {
|
|
throw new Error('No VEVENT found in calendar object');
|
|
}
|
|
const event = new ICAL.Event(vevent);
|
|
|
|
if (eventData.title) event.summary = eventData.title;
|
|
if (eventData.description) event.description = eventData.description;
|
|
if (eventData.location) event.location = eventData.location;
|
|
|
|
if (eventData.url !== undefined) {
|
|
if (eventData.url) {
|
|
vevent.updatePropertyWithValue('url', eventData.url);
|
|
} else {
|
|
vevent.removeProperty('url');
|
|
}
|
|
}
|
|
|
|
if (eventData.start) {
|
|
if (eventData.start.date) {
|
|
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
|
|
event.startDate.isDate = true;
|
|
} else if (eventData.start.dateTime) {
|
|
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
|
|
event.startDate.isDate = false;
|
|
}
|
|
}
|
|
|
|
if (eventData.end) {
|
|
if (eventData.end.date) {
|
|
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
|
|
event.endDate.isDate = true;
|
|
} else if (eventData.end.dateTime) {
|
|
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
|
|
event.endDate.isDate = false;
|
|
}
|
|
}
|
|
|
|
// Update reminders (VALARM)
|
|
if (eventData.reminders !== undefined) {
|
|
const existingAlarms = vevent.getAllSubcomponents('valarm');
|
|
existingAlarms.forEach((a: any) => vevent.removeSubcomponent(a));
|
|
for (const reminder of eventData.reminders) {
|
|
const valarm = new ICAL.Component('valarm');
|
|
valarm.addPropertyWithValue('action', reminder.method === 'email' ? 'EMAIL' : 'DISPLAY');
|
|
const dur = ICAL.Duration.fromString(reminderMinutesToDuration(reminder.minutes));
|
|
valarm.addPropertyWithValue('trigger', dur);
|
|
if (reminder.method !== 'email') valarm.addPropertyWithValue('description', 'Reminder');
|
|
vevent.addSubcomponent(valarm);
|
|
}
|
|
}
|
|
|
|
if (eventData.attendees !== undefined) {
|
|
vevent.removeAllProperties('attendee');
|
|
for (const att of eventData.attendees) {
|
|
const prop = new ICAL.Property('attendee');
|
|
prop.setValue(`mailto:${att.email}`);
|
|
prop.setParameter('cutype', 'INDIVIDUAL');
|
|
prop.setParameter('role', 'REQ-PARTICIPANT');
|
|
prop.setParameter('partstat', 'NEEDS-ACTION');
|
|
if (att.displayName) prop.setParameter('cn', att.displayName);
|
|
vevent.addProperty(prop);
|
|
}
|
|
}
|
|
|
|
if (eventData.attachments !== undefined) {
|
|
vevent.removeAllProperties('attach');
|
|
for (const att of eventData.attachments) {
|
|
vevent.addPropertyWithValue('attach', att.url);
|
|
}
|
|
}
|
|
|
|
if (eventData.busyStatus !== undefined) {
|
|
if (eventData.busyStatus === 'free') vevent.updatePropertyWithValue('transp', 'TRANSPARENT');
|
|
else if (eventData.busyStatus) vevent.updatePropertyWithValue('transp', 'OPAQUE');
|
|
}
|
|
|
|
if (eventData.visibility !== undefined) {
|
|
if (eventData.visibility && eventData.visibility !== 'default') {
|
|
vevent.updatePropertyWithValue('class', eventData.visibility.toUpperCase());
|
|
} else {
|
|
vevent.removeProperty('class');
|
|
}
|
|
}
|
|
|
|
event.sequence = (event.sequence || 0) + 1;
|
|
if (vevent) {
|
|
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
|
|
}
|
|
|
|
const updatedIcalString = comp.toString();
|
|
console.log('[SYNOLOGY CALENDAR] Updating event with iCal:', updatedIcalString);
|
|
|
|
await client.updateObject({
|
|
url: targetObject.url,
|
|
data: updatedIcalString,
|
|
etag: targetObject.etag
|
|
} as any);
|
|
|
|
return {
|
|
id: eventId,
|
|
title: event.summary,
|
|
startDate: event.startDate.toString(),
|
|
endDate: event.endDate.toString(),
|
|
description: event.description,
|
|
location: event.location,
|
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('[SYNOLOGY CALENDAR] Error updating event:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Delete an event
|
|
*/
|
|
/**
|
|
* Delete a specific instance or future instances of a recurring CalDAV event.
|
|
* 'this' mode: adds EXDATE to exclude the specific occurrence.
|
|
* 'future' mode: modifies RRULE to add UNTIL just before the target occurrence.
|
|
*/
|
|
export const deleteRecurringInstance = async (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
calendarUrl: string,
|
|
eventId: string,
|
|
deleteMode: string
|
|
): Promise<void> => {
|
|
try {
|
|
const client = createClient(serverUrl, username, password);
|
|
await loginClient(client, serverUrl, username);
|
|
|
|
// Parse the caldav ID to get objectUrl and occurrence date
|
|
const parts = eventId.split('::');
|
|
if (parts.length < 4 || parts[0] !== 'caldav') {
|
|
throw new Error('Cannot determine occurrence from event ID format');
|
|
}
|
|
const objectUrl = parts[1];
|
|
const occurrenceISO = parts[3]; // e.g. "2026-03-30T12:40:00.000Z"
|
|
|
|
// Fetch the calendar object
|
|
const objects = await client.fetchCalendarObjects({
|
|
calendar: { url: calendarUrl } as any,
|
|
objectUrls: [objectUrl],
|
|
});
|
|
const targetObject = objects?.[0];
|
|
if (!targetObject?.data) {
|
|
throw new Error('Event not found on server');
|
|
}
|
|
|
|
// Parse iCal data
|
|
const jcal = ICAL.parse(targetObject.data);
|
|
const comp = new ICAL.Component(jcal);
|
|
const vevent = comp.getFirstSubcomponent('vevent');
|
|
if (!vevent) {
|
|
throw new Error('No VEVENT found in calendar object');
|
|
}
|
|
|
|
const occDate = new Date(occurrenceISO);
|
|
|
|
if (deleteMode === 'this') {
|
|
// Add EXDATE to exclude this specific occurrence
|
|
const dtstart = vevent.getFirstProperty('dtstart');
|
|
const isAllDay = dtstart?.getParameter('value') === 'DATE';
|
|
|
|
if (isAllDay) {
|
|
const exDate = ICAL.Time.fromJSDate(occDate, true);
|
|
exDate.isDate = true;
|
|
const prop = new ICAL.Property('exdate');
|
|
prop.setParameter('value', 'DATE');
|
|
prop.setValue(exDate);
|
|
vevent.addProperty(prop);
|
|
} else {
|
|
// Use the same timezone as DTSTART if available
|
|
const tzid = dtstart?.getParameter('tzid');
|
|
const exDate = ICAL.Time.fromJSDate(occDate, false);
|
|
const prop = new ICAL.Property('exdate');
|
|
if (tzid) {
|
|
prop.setParameter('tzid', tzid);
|
|
}
|
|
prop.setValue(exDate);
|
|
vevent.addProperty(prop);
|
|
}
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Added EXDATE for occurrence:', occurrenceISO);
|
|
|
|
} else if (deleteMode === 'past') {
|
|
// Move DTSTART forward to the next occurrence after this one
|
|
const event = new ICAL.Event(vevent);
|
|
const rruleProp = vevent.getFirstProperty('rrule');
|
|
if (!rruleProp) {
|
|
throw new Error('No RRULE found on event');
|
|
}
|
|
|
|
// Find the next occurrence after occDate
|
|
const iter = event.iterator();
|
|
let next = iter.next();
|
|
let nextAfter: any = null;
|
|
while (next) {
|
|
const nextDate = next.toJSDate();
|
|
if (nextDate.getTime() > occDate.getTime()) {
|
|
nextAfter = next;
|
|
break;
|
|
}
|
|
next = iter.next();
|
|
}
|
|
|
|
if (!nextAfter) {
|
|
await client.deleteObject({ url: objectUrl, etag: undefined } as any);
|
|
console.log('[SYNOLOGY CALENDAR] No future occurrences, deleted entire event');
|
|
return;
|
|
}
|
|
|
|
// Update DTSTART and DTEND to the next occurrence
|
|
const dtstart = vevent.getFirstProperty('dtstart');
|
|
const oldStart = event.startDate;
|
|
const oldEnd = event.endDate;
|
|
const durationMs = oldEnd.toJSDate().getTime() - oldStart.toJSDate().getTime();
|
|
const newEndDate = new Date(nextAfter.toJSDate().getTime() + durationMs);
|
|
|
|
vevent.updatePropertyWithValue('dtstart', nextAfter);
|
|
vevent.updatePropertyWithValue('dtend', ICAL.Time.fromJSDate(newEndDate, dtstart?.getParameter('tzid') ? false : true));
|
|
|
|
// Adjust COUNT if present
|
|
const rrule = rruleProp.getFirstValue() as any;
|
|
if (rrule.count) {
|
|
const countIter = event.iterator();
|
|
let removed = 0;
|
|
let cn = countIter.next();
|
|
while (cn) {
|
|
if (cn.toJSDate().getTime() <= occDate.getTime()) removed++;
|
|
else break;
|
|
cn = countIter.next();
|
|
}
|
|
rrule.count = Math.max(1, rrule.count - removed);
|
|
rruleProp.setValue(rrule);
|
|
}
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Moved DTSTART forward for past delete, new start:', nextAfter.toString());
|
|
|
|
} else if (deleteMode === 'future') {
|
|
// Modify RRULE to end just before this occurrence
|
|
const rruleProp = vevent.getFirstProperty('rrule');
|
|
if (!rruleProp) {
|
|
throw new Error('No RRULE found on event');
|
|
}
|
|
const rrule = rruleProp.getFirstValue() as any;
|
|
|
|
// Set UNTIL to one second before the occurrence
|
|
const untilDate = new Date(occDate.getTime() - 1000);
|
|
const until = ICAL.Time.fromJSDate(untilDate, true);
|
|
|
|
// Remove COUNT if present, set UNTIL
|
|
delete rrule.count;
|
|
rrule.until = until;
|
|
|
|
// Write modified RRULE back
|
|
rruleProp.setValue(rrule);
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Modified RRULE UNTIL for future delete:', untilDate.toISOString());
|
|
}
|
|
|
|
// PUT the modified object back
|
|
const updatedData = comp.toString();
|
|
await client.updateObject({
|
|
url: objectUrl,
|
|
data: updatedData,
|
|
etag: targetObject.etag,
|
|
} as any);
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Recurring instance delete complete (mode:', deleteMode, ')');
|
|
|
|
} catch (error) {
|
|
console.error('[SYNOLOGY CALENDAR] Error deleting recurring instance:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const deleteEvent = async (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
calendarUrl: string,
|
|
eventId: string
|
|
): Promise<void> => {
|
|
try {
|
|
const client = createClient(serverUrl, username, password);
|
|
await loginClient(client, serverUrl, username);
|
|
|
|
const parsed = parseCaldavId(eventId);
|
|
if (parsed) {
|
|
await client.deleteObject({
|
|
url: parsed.objectUrl,
|
|
etag: undefined,
|
|
} as any);
|
|
console.log('[SYNOLOGY CALENDAR] Event deleted via direct URL (O(1))');
|
|
return;
|
|
}
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
|
|
|
if (!targetCalendar) {
|
|
throw new Error(`Calendar not found: ${calendarUrl}`);
|
|
}
|
|
|
|
const uid = eventId.split('-')[0];
|
|
|
|
const allObjects = await client.fetchCalendarObjects({
|
|
calendar: targetCalendar,
|
|
});
|
|
|
|
const targetObject = allObjects.find(obj => {
|
|
if (obj.data) {
|
|
return obj.data.includes(`UID:${uid}`);
|
|
}
|
|
return obj.url.includes(uid);
|
|
});
|
|
|
|
if (!targetObject) {
|
|
console.warn('[SYNOLOGY CALENDAR] Event to delete not found, maybe already deleted?');
|
|
return;
|
|
}
|
|
|
|
await client.deleteObject({
|
|
url: targetObject.url,
|
|
etag: targetObject.etag
|
|
} as any);
|
|
|
|
console.log('[SYNOLOGY CALENDAR] Event deleted via legacy scan');
|
|
|
|
} catch (error) {
|
|
console.error('[SYNOLOGY CALENDAR] Error deleting event:', error);
|
|
throw error;
|
|
}
|
|
};
|