(front) implement EventCalendarAdapter

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 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:33:52 +01:00
parent a4e2e14399
commit 0d3c381e80
2 changed files with 985 additions and 0 deletions

View File

@@ -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<string, string>
}
// ============================================================================
// 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<string, IcsRecurrenceRule>()
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<CalDavExtendedProps>
// 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<CalDavExtendedProps> | 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<CalDavExtendedProps> | undefined
return extProps?.eventUrl
}
/**
* Get the ETag from an EventCalendar event
*/
public getEtag(ecEvent: EventCalendarEvent): string | undefined {
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
return extProps?.etag
}
/**
* Check if an EventCalendar event is a recurring instance
*/
public isRecurringInstance(ecEvent: EventCalendarEvent): boolean {
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
return extProps?.isRecurringInstance ?? false
}
/**
* Check if an EventCalendar event has recurrence rule
*/
public hasRecurrenceRule(ecEvent: EventCalendarEvent): boolean {
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | 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<CalDavExtendedProps> | undefined
return extProps?.attendees ?? []
}
/**
* Get organizer from an EventCalendar event
*/
public getOrganizer(ecEvent: EventCalendarEvent): ExtendedOrganizer | undefined {
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | 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<CalDavExtendedProps> | undefined
return extProps?.location
}
/**
* Get description from an EventCalendar event
*/
public getDescription(ecEvent: EventCalendarEvent): string | undefined {
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | 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<string, string> {
const colorMap = new Map<string, string>()
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<string, string>
): 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<string, number> = {
'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<string, ExtendedAttendee>()
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)
}

View File

@@ -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)
}
}