From 0d3c381e80327c2e9c41a386bd16d80557b20054 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:33:52 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20implement=20EventCalendarAda?= =?UTF-8?q?pter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add EventCalendarAdapter for converting between CalDAV events and scheduler-compatible format. Include VCard component for contact handling in calendar invitations. Co-Authored-By: Claude Opus 4.5 --- .../services/dav/EventCalendarAdapter.ts | 942 ++++++++++++++++++ .../calendar/services/dav/VCardComponent.ts | 43 + 2 files changed, 985 insertions(+) create mode 100644 src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts new file mode 100644 index 0000000..8bb0db9 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts @@ -0,0 +1,942 @@ +/** + * EventCalendarAdapter - Conversion service between CalDAV and EventCalendar formats + * + * This adapter provides bidirectional conversion between CalDAV data structures + * (IcsEvent, CalDavCalendar, etc.) and EventCalendar (vkurko/calendar) format. + * + * EventCalendar: https://github.com/vkurko/calendar + * + * @example + * ```ts + * const adapter = new EventCalendarAdapter() + * + * // Convert CalDAV events to EventCalendar format + * const ecEvents = adapter.toEventCalendarEvents(caldavEvents, { calendarColors }) + * + * // Convert EventCalendar event back to CalDAV + * const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + * ``` + */ + +import type { IcsCalendar, IcsDateObject, IcsEvent, IcsRecurrenceRule, IcsAttendee, IcsOrganizer, IcsDuration } from 'ts-ics' +import type { CalDavCalendar, CalDavEvent } from './types/caldav-service' +import type { + EventCalendarEvent, + EventCalendarEventInput, + EventCalendarResource, + EventCalendarDuration, + CalDavToEventCalendarOptions, + EventCalendarToCalDavOptions, +} from './types/event-calendar' + +// Extended attendee/organizer types for UI display (has displayName instead of name) +type ExtendedAttendee = { + email: string + displayName?: string + role?: IcsAttendee['role'] + status?: string + rsvp?: boolean +} + +type ExtendedOrganizer = { + email: string + displayName?: string +} + +// ============================================================================ +// Extended Types for conversion metadata +// ============================================================================ + +/** + * Extended props stored in EventCalendarEvent.extendedProps + * Contains original CalDAV data for round-trip conversion + */ +export type CalDavExtendedProps = { + /** Original ICS UID */ + uid: string + /** Calendar URL this event belongs to */ + calendarUrl: string + /** Event URL for updates/deletes */ + eventUrl?: string + /** ETag for optimistic concurrency */ + etag?: string + /** Original recurrence rule */ + recurrenceRule?: IcsRecurrenceRule + /** Recurrence ID for recurring event instances */ + recurrenceId?: Date + /** Is this a recurring event instance */ + isRecurringInstance?: boolean + /** Original timezone */ + timezone?: string + /** Event sequence number */ + sequence?: number + /** Event status */ + status?: string + /** Event location */ + location?: string + /** Event description */ + description?: string + /** Event organizer */ + organizer?: ExtendedOrganizer + /** Event attendees */ + attendees?: ExtendedAttendee[] + /** Event categories/tags */ + categories?: string[] + /** Event priority (1-9, 1 highest) */ + priority?: number + /** Event URL */ + url?: string + /** Creation timestamp */ + created?: Date + /** Last modified timestamp */ + lastModified?: Date + /** Custom X-properties */ + customProperties?: Record +} + +// ============================================================================ +// EventCalendarAdapter Class +// ============================================================================ + +export class EventCalendarAdapter { + private defaultOptions: CalDavToEventCalendarOptions = { + defaultEventColor: '#3788d8', + defaultTextColor: '#ffffff', + includeRecurringInstances: true, + } + + // ============================================================================ + // CalDAV -> EventCalendar Conversions + // ============================================================================ + + /** + * Convert CalDAV events to EventCalendar format + */ + public toEventCalendarEvents( + caldavEvents: CalDavEvent[], + options?: CalDavToEventCalendarOptions + ): EventCalendarEvent[] { + const opts = { ...this.defaultOptions, ...options } + const events: EventCalendarEvent[] = [] + + for (const caldavEvent of caldavEvents) { + const icsEvents = caldavEvent.data.events ?? [] + + // Build a map of source events (with recurrenceRule) by UID + const sourceEventRules = new Map() + for (const icsEvent of icsEvents) { + if (icsEvent.recurrenceRule && !icsEvent.recurrenceId) { + sourceEventRules.set(icsEvent.uid, icsEvent.recurrenceRule) + } + } + + for (const icsEvent of icsEvents) { + // Skip recurring source events if we only want instances + if (icsEvent.recurrenceRule && !icsEvent.recurrenceId && !opts.includeRecurringInstances) { + continue + } + + // If this is a recurring instance without recurrenceRule, copy it from source + const enrichedEvent = { ...icsEvent } + if (icsEvent.recurrenceId && !icsEvent.recurrenceRule) { + const sourceRule = sourceEventRules.get(icsEvent.uid) + if (sourceRule) { + enrichedEvent.recurrenceRule = sourceRule + } + } + + const ecEvent = this.icsEventToEventCalendarEvent( + enrichedEvent, + caldavEvent.calendarUrl, + caldavEvent.url, + caldavEvent.etag, + opts + ) + + events.push(ecEvent) + } + } + + return events + } + + /** + * Convert a single IcsEvent to EventCalendarEvent + */ + public icsEventToEventCalendarEvent( + icsEvent: IcsEvent, + calendarUrl: string, + eventUrl?: string, + etag?: string, + options?: CalDavToEventCalendarOptions + ): EventCalendarEvent { + const opts = { ...this.defaultOptions, ...options } + + // Generate unique ID + const id = opts.eventIdGenerator + ? opts.eventIdGenerator(icsEvent, calendarUrl) + : this.generateEventId(icsEvent) + + // Determine colors + const calendarColor = opts.calendarColors?.get(calendarUrl) + const backgroundColor = calendarColor ?? opts.defaultEventColor + const textColor = opts.defaultTextColor + + // Convert dates + const start = this.icsDateToJsDate(icsEvent.start) + const end = icsEvent.end + ? this.icsDateToJsDate(icsEvent.end) + : icsEvent.duration + ? this.addIcsDurationToDate(start, icsEvent.duration) + : start + + // Determine if all-day event + const allDay = icsEvent.start.type === 'DATE' + + // EventCalendar expects ISO strings but interprets them in browser local time + // We need to create ISO strings that preserve the local time components + // instead of using toISOString() which converts to UTC + + // Build extended props + const extendedProps: CalDavExtendedProps = { + uid: icsEvent.uid, + calendarUrl, + eventUrl, + etag, + recurrenceRule: icsEvent.recurrenceRule, + recurrenceId: icsEvent.recurrenceId?.value.date, + isRecurringInstance: !!icsEvent.recurrenceId, + timezone: icsEvent.start.local?.timezone, + sequence: icsEvent.sequence, + status: icsEvent.status, + location: icsEvent.location, + description: icsEvent.description, + organizer: icsEvent.organizer + ? { + email: icsEvent.organizer.email, + displayName: icsEvent.organizer.name, + } + : undefined, + attendees: this.deduplicateAttendees(icsEvent.attendees?.map((att) => ({ + email: att.email, + displayName: att.name, + role: att.role as ExtendedAttendee['role'], + status: att.partstat as ExtendedAttendee['status'], + rsvp: att.rsvp, + }))), + categories: icsEvent.categories, + priority: icsEvent.priority != null ? Number(icsEvent.priority) : undefined, + url: icsEvent.url, + created: icsEvent.created ? this.icsDateToJsDate(icsEvent.created) : undefined, + lastModified: icsEvent.lastModified ? this.icsDateToJsDate(icsEvent.lastModified) : undefined, + } + + // Add any custom extended props + if (opts.extendedPropsExtractor) { + Object.assign(extendedProps, opts.extendedPropsExtractor(icsEvent)) + } + + return { + id, + start: allDay ? this.dateToDateOnlyString(start) : this.dateToLocalISOString(start), + end: allDay ? this.dateToDateOnlyString(end) : this.dateToLocalISOString(end), + allDay, + resourceId: calendarUrl, + resourceIds: [calendarUrl], + title: icsEvent.summary ?? '', + backgroundColor, + textColor, + editable: true, + extendedProps, + } + } + + /** + * Convert Date to date-only ISO string (YYYY-MM-DD) + * For all-day events, use UTC components since the Date is UTC midnight + */ + private dateToDateOnlyString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0') + const year = date.getUTCFullYear() + const month = pad(date.getUTCMonth() + 1) + const day = pad(date.getUTCDate()) + return `${year}-${month}-${day}` + } + + /** + * Convert Date to ISO string preserving local time components + * Unlike toISOString() which converts to UTC, this preserves the browser's local time + */ + private dateToLocalISOString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0') + const year = date.getFullYear() + const month = pad(date.getMonth() + 1) + const day = pad(date.getDate()) + const hours = pad(date.getHours()) + const minutes = pad(date.getMinutes()) + const seconds = pad(date.getSeconds()) + const ms = date.getMilliseconds().toString().padStart(3, '0') + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}` + } + + /** + * Convert CalDAV calendars to EventCalendar resources + */ + public toEventCalendarResources(calendars: CalDavCalendar[]): EventCalendarResource[] { + return calendars.map((cal) => ({ + id: cal.url, + title: cal.displayName || 'Unnamed Calendar', + eventBackgroundColor: cal.color, + extendedProps: { + description: cal.description, + timezone: cal.timezone, + ctag: cal.ctag, + syncToken: cal.syncToken, + components: cal.components, + }, + })) + } + + // ============================================================================ + // EventCalendar -> CalDAV Conversions + // ============================================================================ + + /** + * Convert EventCalendar event to IcsEvent + */ + public toIcsEvent( + ecEvent: EventCalendarEvent | EventCalendarEventInput, + options?: EventCalendarToCalDavOptions + ): IcsEvent { + const opts = options ?? {} + const extProps = (ecEvent.extendedProps ?? {}) as Partial + + // Get or generate UID + const uid = extProps.uid ?? opts.uidGenerator?.() ?? crypto.randomUUID() + + // Convert dates + // For all-day events, parse date strings carefully to avoid timezone issues + const parseDate = (dateValue: Date | string, isAllDay: boolean): Date => { + if (dateValue instanceof Date) { + return dateValue + } + + // If all-day and string format is YYYY-MM-DD, parse components directly as UTC + // All-day events in ICS are stored as UTC midnight + if (isAllDay && typeof dateValue === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateValue)) { + const [year, month, day] = dateValue.split('-').map(Number) + const result = new Date(Date.UTC(year, month - 1, day)) + console.log('[EventCalendarAdapter] Parsing all-day date:', dateValue, '→', result) + return result + } + + return new Date(dateValue) + } + + const isAllDay = ecEvent.allDay ?? false + console.log('[EventCalendarAdapter] toIcsEvent - allDay:', isAllDay, 'start:', ecEvent.start, 'end:', ecEvent.end) + + const startDate = parseDate(ecEvent.start, isAllDay) + const endDate = ecEvent.end + ? parseDate(ecEvent.end, isAllDay) + : startDate + + console.log('[EventCalendarAdapter] Parsed dates - start:', startDate, 'end:', endDate) + + // Determine timezone + const timezone = extProps.timezone ?? opts.defaultTimezone + + // If event has extProps.timezone, it came from the server and dates are already "fake UTC" + const isFakeUtc = !!extProps.timezone + + // Build IcsEvent + // Note: EventCalendar already uses exclusive end dates for all-day events + const icsEvent: IcsEvent = { + uid, + stamp: { date: new Date() }, + start: this.jsDateToIcsDate(startDate, isAllDay, timezone, isFakeUtc), + end: this.jsDateToIcsDate(endDate, isAllDay, timezone, isFakeUtc), + summary: typeof ecEvent.title === 'string' ? ecEvent.title : '', + sequence: (extProps.sequence ?? 0) + 1, + } + + // Add optional properties from extended props + if (extProps.location) icsEvent.location = extProps.location + if (extProps.description) icsEvent.description = extProps.description + if (extProps.status && this.isValidStatus(extProps.status)) { + icsEvent.status = extProps.status + } + if (extProps.categories) icsEvent.categories = extProps.categories + if (extProps.priority != null) icsEvent.priority = String(extProps.priority) + if (extProps.url) icsEvent.url = extProps.url + if (extProps.created) icsEvent.created = this.jsDateToIcsDate(extProps.created, false, timezone, isFakeUtc) + if (extProps.recurrenceRule) icsEvent.recurrenceRule = extProps.recurrenceRule + + // Convert recurrence ID for recurring instances + if (extProps.recurrenceId) { + icsEvent.recurrenceId = { + value: this.jsDateToIcsDate(extProps.recurrenceId, ecEvent.allDay ?? false, timezone, isFakeUtc), + } + } + + // Convert organizer + if (extProps.organizer) { + const organizer: IcsOrganizer = { + email: extProps.organizer.email, + } + if (extProps.organizer.displayName) { + organizer.name = extProps.organizer.displayName + } + icsEvent.organizer = organizer + } + + // Convert attendees + if (extProps.attendees && extProps.attendees.length > 0) { + icsEvent.attendees = extProps.attendees.map((att): IcsAttendee => ({ + email: att.email, + name: att.displayName, + role: att.role, + partstat: (att.status as IcsAttendee['partstat']) ?? 'NEEDS-ACTION', + rsvp: att.rsvp, + })) + } + + return icsEvent + } + + /** + * Check if a status string is a valid ICS event status + */ + private isValidStatus(status: string): status is 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED' { + return ['TENTATIVE', 'CONFIRMED', 'CANCELLED'].includes(status) + } + + /** + * Convert EventCalendar event to a full IcsCalendar object + */ + public toIcsCalendar( + ecEvent: EventCalendarEvent | EventCalendarEventInput, + options?: EventCalendarToCalDavOptions + ): IcsCalendar { + const icsEvent = this.toIcsEvent(ecEvent, options) + + return { + prodId: '-//EventCalendarAdapter//NONSGML v1.0//EN', + version: '2.0', + events: [icsEvent], + } + } + + /** + * Get the calendar URL from an EventCalendar event + */ + public getCalendarUrl(ecEvent: EventCalendarEvent, defaultCalendarUrl?: string): string | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.calendarUrl ?? defaultCalendarUrl + } + + /** + * Get the event URL from an EventCalendar event + */ + public getEventUrl(ecEvent: EventCalendarEvent): string | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.eventUrl + } + + /** + * Get the ETag from an EventCalendar event + */ + public getEtag(ecEvent: EventCalendarEvent): string | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.etag + } + + /** + * Check if an EventCalendar event is a recurring instance + */ + public isRecurringInstance(ecEvent: EventCalendarEvent): boolean { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.isRecurringInstance ?? false + } + + /** + * Check if an EventCalendar event has recurrence rule + */ + public hasRecurrenceRule(ecEvent: EventCalendarEvent): boolean { + const extProps = ecEvent.extendedProps as Partial | undefined + return !!extProps?.recurrenceRule + } + + // ============================================================================ + // Duration Conversions + // ============================================================================ + + /** + * Convert EventCalendar duration to seconds + */ + public durationToSeconds(duration: EventCalendarDuration): number { + let seconds = 0 + if (duration.years) seconds += duration.years * 365.25 * 24 * 60 * 60 + if (duration.months) seconds += duration.months * 30.44 * 24 * 60 * 60 + if (duration.weeks) seconds += duration.weeks * 7 * 24 * 60 * 60 + if (duration.days) seconds += duration.days * 24 * 60 * 60 + if (duration.hours) seconds += duration.hours * 60 * 60 + if (duration.minutes) seconds += duration.minutes * 60 + if (duration.seconds) seconds += duration.seconds + return Math.round(seconds) + } + + /** + * Convert seconds to EventCalendar duration + */ + public secondsToDuration(totalSeconds: number): EventCalendarDuration { + const days = Math.floor(totalSeconds / (24 * 60 * 60)) + const remainingAfterDays = totalSeconds % (24 * 60 * 60) + const hours = Math.floor(remainingAfterDays / (60 * 60)) + const remainingAfterHours = remainingAfterDays % (60 * 60) + const minutes = Math.floor(remainingAfterHours / 60) + const seconds = remainingAfterHours % 60 + + const duration: EventCalendarDuration = {} + if (days) duration.days = days + if (hours) duration.hours = hours + if (minutes) duration.minutes = minutes + if (seconds) duration.seconds = seconds + + return duration + } + + /** + * Calculate duration between two EventCalendar events (for drop/resize delta) + */ + public calculateDelta( + oldEvent: EventCalendarEvent, + newEvent: EventCalendarEvent + ): { startDelta: EventCalendarDuration; endDelta: EventCalendarDuration } { + const oldStart = oldEvent.start instanceof Date ? oldEvent.start : new Date(oldEvent.start) + const newStart = newEvent.start instanceof Date ? newEvent.start : new Date(newEvent.start) + const oldEnd = oldEvent.end instanceof Date ? oldEvent.end : new Date(oldEvent.end ?? oldStart) + const newEnd = newEvent.end instanceof Date ? newEvent.end : new Date(newEvent.end ?? newStart) + + const startDeltaMs = newStart.getTime() - oldStart.getTime() + const endDeltaMs = newEnd.getTime() - oldEnd.getTime() + + return { + startDelta: this.secondsToDuration(Math.round(startDeltaMs / 1000)), + endDelta: this.secondsToDuration(Math.round(endDeltaMs / 1000)), + } + } + + // ============================================================================ + // Attendee/Organizer Helpers + // ============================================================================ + + /** + * Get attendees from an EventCalendar event + */ + public getAttendees(ecEvent: EventCalendarEvent): ExtendedAttendee[] { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.attendees ?? [] + } + + /** + * Get organizer from an EventCalendar event + */ + public getOrganizer(ecEvent: EventCalendarEvent): ExtendedOrganizer | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.organizer + } + + /** + * Set attendees on an EventCalendar event (returns new event) + */ + public setAttendees( + ecEvent: EventCalendarEvent, + attendees: ExtendedAttendee[] + ): EventCalendarEvent { + return { + ...ecEvent, + extendedProps: { + ...(ecEvent.extendedProps ?? {}), + attendees, + }, + } + } + + /** + * Set organizer on an EventCalendar event (returns new event) + */ + public setOrganizer( + ecEvent: EventCalendarEvent, + organizer: ExtendedOrganizer + ): EventCalendarEvent { + return { + ...ecEvent, + extendedProps: { + ...(ecEvent.extendedProps ?? {}), + organizer, + }, + } + } + + // ============================================================================ + // Event Property Helpers + // ============================================================================ + + /** + * Get location from an EventCalendar event + */ + public getLocation(ecEvent: EventCalendarEvent): string | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.location + } + + /** + * Get description from an EventCalendar event + */ + public getDescription(ecEvent: EventCalendarEvent): string | undefined { + const extProps = ecEvent.extendedProps as Partial | undefined + return extProps?.description + } + + /** + * Set location on an EventCalendar event (returns new event) + */ + public setLocation(ecEvent: EventCalendarEvent, location: string): EventCalendarEvent { + return { + ...ecEvent, + extendedProps: { + ...(ecEvent.extendedProps ?? {}), + location, + }, + } + } + + /** + * Set description on an EventCalendar event (returns new event) + */ + public setDescription(ecEvent: EventCalendarEvent, description: string): EventCalendarEvent { + return { + ...ecEvent, + extendedProps: { + ...(ecEvent.extendedProps ?? {}), + description, + }, + } + } + + // ============================================================================ + // Color Helpers + // ============================================================================ + + /** + * Create a color map from calendars + */ + public createCalendarColorMap(calendars: CalDavCalendar[]): Map { + const colorMap = new Map() + for (const cal of calendars) { + if (cal.color) { + colorMap.set(cal.url, cal.color) + } + } + return colorMap + } + + /** + * Apply calendar colors to events + */ + public applyCalendarColors( + events: EventCalendarEvent[], + calendarColors: Map + ): EventCalendarEvent[] { + return events.map((event) => { + const calendarUrl = this.getCalendarUrl(event) + if (calendarUrl && calendarColors.has(calendarUrl)) { + return { + ...event, + backgroundColor: calendarColors.get(calendarUrl), + } + } + return event + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Deduplicate attendees by email, keeping the one with the most "advanced" status. + * + * This fixes a SabreDAV bug where REPLY processing can add duplicate attendees + * instead of updating the existing one (due to email case sensitivity or format differences). + * + * Status priority (most to least advanced): ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION + */ + private deduplicateAttendees(attendees?: ExtendedAttendee[]): ExtendedAttendee[] | undefined { + if (!attendees || attendees.length === 0) { + return attendees + } + + // Status priority map (higher = more definitive response) + const statusPriority: Record = { + 'ACCEPTED': 4, + 'TENTATIVE': 3, + 'DECLINED': 2, + 'NEEDS-ACTION': 1, + } + + const getStatusPriority = (status?: string): number => { + return statusPriority[status ?? 'NEEDS-ACTION'] ?? 0 + } + + // Group by normalized email (lowercase) + const byEmail = new Map() + + for (const attendee of attendees) { + const normalizedEmail = attendee.email.toLowerCase().trim() + const existing = byEmail.get(normalizedEmail) + + if (!existing) { + // First occurrence of this email + byEmail.set(normalizedEmail, attendee) + } else { + // Duplicate found - keep the one with higher status priority + const existingPriority = getStatusPriority(existing.status) + const newPriority = getStatusPriority(attendee.status) + + if (newPriority > existingPriority) { + // New attendee has more definitive status - replace + byEmail.set(normalizedEmail, attendee) + } else if (newPriority === existingPriority && attendee.displayName && !existing.displayName) { + // Same status but new one has display name - prefer it + byEmail.set(normalizedEmail, attendee) + } + // Otherwise keep existing + } + } + + return Array.from(byEmail.values()) + } + + /** + * Generate a unique event ID from IcsEvent + */ + private generateEventId(icsEvent: IcsEvent): string { + if (icsEvent.recurrenceId) { + return `${icsEvent.uid}_${icsEvent.recurrenceId.value.date.getTime()}` + } + return icsEvent.uid + } + + /** + * Convert IcsDateObject to JavaScript Date + */ + private icsDateToJsDate(icsDate: IcsDateObject): Date { + // Use local date if available, otherwise use UTC date + if (icsDate.local?.date) { + return icsDate.local.date + } + return icsDate.date + } + + /** + * Convert JavaScript Date to IcsDateObject + * + * IMPORTANT: EventCalendar returns dates in browser local time. + * ts-ics uses date.getUTCHours() etc. to generate ICS, so we need to + * create a "fake UTC" date where UTC components match the local time we want. + * + * Example: User in Paris (UTC+1) drags event to 15:00 local + * - Input date: Date representing 15:00 local (14:00 UTC internally) + * - We want ICS: DTSTART:20260121T150000Z or DTSTART;TZID=Europe/Paris:20260121T150000 + * - Solution: Create date where getUTCHours() = 15 + * + * @param date - The date to convert + * @param allDay - Whether this is an all-day event + * @param timezone - The timezone to use + * @param isFakeUtc - If true, date is already "fake UTC" (use getUTC* methods) + */ + private jsDateToIcsDate(date: Date, allDay: boolean, timezone?: string, isFakeUtc = false): IcsDateObject { + if (allDay) { + // For all-day events, use DATE type (no time component) + // Create a UTC date with the local date components + const utcDate = new Date(Date.UTC( + isFakeUtc ? date.getUTCFullYear() : date.getFullYear(), + isFakeUtc ? date.getUTCMonth() : date.getMonth(), + isFakeUtc ? date.getUTCDate() : date.getDate() + )) + return { + type: 'DATE', + date: utcDate, + } + } + + // For timed events, create a "fake UTC" date where UTC components = local components + // This ensures ts-ics generates the correct time in the ICS output + const fakeUtcDate = isFakeUtc + ? date // Already fake UTC, use as-is + : new Date(Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds() + )) + + const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone + const tzOffset = this.getTimezoneOffset(isFakeUtc ? date : new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes() + ), tz) + + return { + type: 'DATE-TIME', + date: fakeUtcDate, + local: { + date: fakeUtcDate, + timezone: tz, + tzoffset: tzOffset, + }, + } + } + + /** + * Get timezone offset string for a date and timezone + * Returns format like "+0200" or "-0500" + */ + public getTimezoneOffset(date: Date, timezone: string): string { + try { + // Create formatter for the timezone + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'longOffset', + }) + const parts = formatter.formatToParts(date) + const tzPart = parts.find((p) => p.type === 'timeZoneName') + + if (tzPart?.value) { + // Convert "GMT+02:00" to "+0200" + const match = tzPart.value.match(/GMT([+-])(\d{1,2}):?(\d{2})?/) + if (match) { + const sign = match[1] + const hours = match[2].padStart(2, '0') + const minutes = (match[3] || '00').padStart(2, '0') + return `${sign}${hours}${minutes}` + } + } + return '+0000' + } catch { + return '+0000' + } + } + + /** + * Add ICS duration object to a date + */ + private addIcsDurationToDate(date: Date, duration: IcsDuration): Date { + const result = new Date(date) + + if (duration.weeks) result.setDate(result.getDate() + duration.weeks * 7) + if (duration.days) result.setDate(result.getDate() + duration.days) + if (duration.hours) result.setHours(result.getHours() + duration.hours) + if (duration.minutes) result.setMinutes(result.getMinutes() + duration.minutes) + if (duration.seconds) result.setSeconds(result.getSeconds() + duration.seconds) + + return result + } + + /** + * Add string duration to a date (ISO 8601 format) + */ + private addDurationToDate(date: Date, duration: string): Date { + // Parse ISO 8601 duration (P1D, PT1H, etc.) + const result = new Date(date) + + const regex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/ + const match = duration.match(regex) + + if (!match) return result + + const [, years, months, weeks, days, hours, minutes, seconds] = match + + if (years) result.setFullYear(result.getFullYear() + parseInt(years)) + if (months) result.setMonth(result.getMonth() + parseInt(months)) + if (weeks) result.setDate(result.getDate() + parseInt(weeks) * 7) + if (days) result.setDate(result.getDate() + parseInt(days)) + if (hours) result.setHours(result.getHours() + parseInt(hours)) + if (minutes) result.setMinutes(result.getMinutes() + parseInt(minutes)) + if (seconds) result.setSeconds(result.getSeconds() + parseInt(seconds)) + + return result + } +} + +// ============================================================================ +// Factory & Singleton +// ============================================================================ + +let _adapterInstance: EventCalendarAdapter | null = null + +/** + * Get or create a singleton adapter instance + */ +export function getEventCalendarAdapter(): EventCalendarAdapter { + if (!_adapterInstance) { + _adapterInstance = new EventCalendarAdapter() + } + return _adapterInstance +} + +/** + * Create a new adapter instance + */ +export function createEventCalendarAdapter(): EventCalendarAdapter { + return new EventCalendarAdapter() +} + +// ============================================================================ +// Standalone Helper Functions +// ============================================================================ + +/** + * Quick conversion from CalDAV events to EventCalendar format + */ +export function caldavToEventCalendar( + caldavEvents: CalDavEvent[], + options?: CalDavToEventCalendarOptions +): EventCalendarEvent[] { + return getEventCalendarAdapter().toEventCalendarEvents(caldavEvents, options) +} + +/** + * Quick conversion from EventCalendar event to IcsEvent + */ +export function eventCalendarToIcs( + ecEvent: EventCalendarEvent | EventCalendarEventInput, + options?: EventCalendarToCalDavOptions +): IcsEvent { + return getEventCalendarAdapter().toIcsEvent(ecEvent, options) +} + +/** + * Quick conversion from EventCalendar event to IcsCalendar + */ +export function eventCalendarToIcsCalendar( + ecEvent: EventCalendarEvent | EventCalendarEventInput, + options?: EventCalendarToCalDavOptions +): IcsCalendar { + return getEventCalendarAdapter().toIcsCalendar(ecEvent, options) +} + +/** + * Quick conversion from CalDAV calendars to EventCalendar resources + */ +export function calendarsToResources(calendars: CalDavCalendar[]): EventCalendarResource[] { + return getEventCalendarAdapter().toEventCalendarResources(calendars) +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts new file mode 100644 index 0000000..b57e74a --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts @@ -0,0 +1,43 @@ +import ICAL from 'ical.js' + +export class VCardComponent { + + public component: ICAL.Component + + public constructor(component: ICAL.Component) { + if (component) this.component = component + else this.component = new ICAL.Component('vcard') + + } + + get version() { return this._getProp('version') as string } + set version(value: string) { this._setProp('version', value) } + + get uid() { return this._getProp('uid') as string } + set uid(value: string) { this._setProp('uid', value) } + + get email() { return this._getProp('email') as (string | null) } + set email(value: string | null) { this._setProp('email', value) } + + get name() { + return this.version.startsWith('2') + ? (this._getProp('n') as string[]).filter(n => !!n).reverse().join(' ') + : this._getProp('fn') as string + } + set name(value: string) { + if (this.version.startsWith('2')) { + const [name, family] = value.split(' ', 1) + this._setProp('n', [family ?? '', name, '', '', '']) + } else { + this._setProp('fn', value) + } + } + + private _setProp(name: string, value: unknown) { + this.component.updatePropertyWithValue(name, value) + } + + private _getProp(name: string): unknown { + return this.component.getFirstPropertyValue(name) + } +}