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 { const result: Pick = {}; 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 = { '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 = { '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 => { 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 => { 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 = 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(); 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 => { 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 = { 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 => { 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 => { 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 => { 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; } };