My-Weekly-ToDo-List/src/lib/synology-calendar.ts
mARTin fb7484c357 fix: CalDAV TZID parsing + recurring event icon on all providers
- 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>
2026-03-24 10:24:38 +01:00

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;
}
};