diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/dav-helper.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/dav-helper.ts new file mode 100644 index 0000000..c170a7f --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/dav-helper.ts @@ -0,0 +1,279 @@ +import { convertIcsCalendar, convertIcsTimezone, generateIcsCalendar, type IcsCalendar } from 'ts-ics' +import { getIcalTimezoneBlock } from './ical-timezones' +import { + createAccount, + fetchCalendars as davFetchCalendars, + fetchCalendarObjects as davFetchCalendarObjects, + createCalendarObject as davCreateCalendarObject, + updateCalendarObject as davUpdateCalendarObject, + deleteCalendarObject as davDeleteCalendarObject, + DAVNamespaceShort, + propfind, + type DAVCalendar, + type DAVCalendarObject, + type DAVAddressBook, + fetchAddressBooks as davFetchAddressBooks, + fetchVCards as davFetchVCards, +} from 'tsdav' +import { isServerSource } from './types-helper' +import type { Calendar, CalendarObject } from '../types/calendar' +import type { CalendarSource, ServerSource, CalendarResponse, AddressBookSource } from '../types/options' +import type { AddressBook, AddressBookObject } from '../types/addressbook' +import ICAL from 'ical.js' + +export function getEventObjectString(event: IcsCalendar) { + return generateIcsCalendar(event) +} + +export async function fetchCalendars(source: ServerSource | CalendarSource): Promise { + if (isServerSource(source)) { + const account = await createAccount({ + account: { serverUrl: source.serverUrl, accountType: 'caldav' }, + headers: source.headers, + fetchOptions: source.fetchOptions, + }) + const calendars = await davFetchCalendars({ account, headers: source.headers, fetchOptions: source.fetchOptions }) + const result = calendars.map(calendar => ({ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions, uid: crypto.randomUUID() })) + return result + } else { + const calendar = await davFetchCalendar({ + url: source.calendarUrl, + headers: source.headers, + fetchOptions: source.fetchOptions, + }) + const result = [{ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.calendarUid }] + return result + } +} + +export async function fetchCalendarObjects( + calendar: Calendar, + timeRange?: { start: string; end: string; }, + expand?: boolean, +): Promise<{ calendarObjects: CalendarObject[], recurringObjects: CalendarObject[] }> { + const davCalendarObjects = await davFetchCalendarObjects({ + calendar: calendar, + timeRange, expand, + headers: calendar.headers, + fetchOptions: calendar.fetchOptions, + }) + const calendarObjects = davCalendarObjects.map(o => ({ + url: o.url, + etag: o.etag, + data: convertIcsCalendar(undefined, o.data), + calendarUrl: calendar.url, + })) + const recurringObjectsUrls = new Set( + calendarObjects + .filter(c => c.data.events?.find(e => e.recurrenceId)) + .map(c => c.url), + ) + const davRecurringObjects = recurringObjectsUrls.size == 0 + ? [] + : await davFetchCalendarObjects({ + calendar: calendar, + objectUrls: Array.from(recurringObjectsUrls), + headers: calendar.headers, + fetchOptions: calendar.fetchOptions, + }) + const recurringObjects = davRecurringObjects.map(o => ({ + url: o.url, + etag: o.etag, + data: convertIcsCalendar(undefined, o.data), + calendarUrl: calendar.url, + })) + return { calendarObjects, recurringObjects } +} + +export async function createCalendarObject( + calendar: Calendar, + calendarObjectData: IcsCalendar, +): Promise { + validateTimezones(calendarObjectData) + for (const event of calendarObjectData.events ?? []) event.uid = crypto.randomUUID() + const uid = calendarObjectData.events?.[0].uid ?? crypto.randomUUID() + const iCalString = getEventObjectString(calendarObjectData) + const response = await davCreateCalendarObject({ + calendar, + iCalString, + filename: `${uid}.ics`, + headers: calendar.headers, + fetchOptions: calendar.fetchOptions, + }) + return { response, ical: iCalString } +} + +export async function updateCalendarObject( + calendar: Calendar, + calendarObject: CalendarObject, +): Promise { + validateTimezones(calendarObject.data) + const davCalendarObject: DAVCalendarObject = { + url: calendarObject.url, + etag: calendarObject.etag, + data: getEventObjectString(calendarObject.data), + } + const response = await davUpdateCalendarObject({ + calendarObject: davCalendarObject, + headers: calendar.headers, + fetchOptions: calendar.fetchOptions, + }) + return { response, ical: davCalendarObject.data } +} + +export async function deleteCalendarObject( + calendar: Calendar, + calendarObject: CalendarObject, +): Promise { + validateTimezones(calendarObject.data) + const davCalendarObject: DAVCalendarObject = { + url: calendarObject.url, + etag: calendarObject.etag, + data: getEventObjectString(calendarObject.data), + } + const response = await davDeleteCalendarObject({ + calendarObject: davCalendarObject, + headers: calendar.headers, + fetchOptions: calendar.fetchOptions, + }) + return { response, ical: davCalendarObject.data } + +} + +function validateTimezones(calendarObjectData: IcsCalendar) { + const calendar = calendarObjectData + const usedTimezones = calendar.events?.flatMap(e => [e.start.local?.timezone, e.end?.local?.timezone]) + const wantedTzIds = new Set(usedTimezones?.filter(s => s !== undefined)) + calendar.timezones ??= [] + + // Remove extra timezones + calendar.timezones = calendar.timezones.filter(tz => wantedTzIds.has(tz.id)) + + // Add missing timezones + wantedTzIds.forEach(tzid => { + if (tzid && calendar.timezones!.findIndex(t => t.id === tzid) === -1) { + const tzBlock = getIcalTimezoneBlock(tzid)[0] + if (tzBlock) { + calendar.timezones!.push(convertIcsTimezone(undefined, tzBlock)) + } + } + }) +} + +// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/calendar.ts, fetchCalendars +async function davFetchCalendar(params: { + url: string, + headers?: Record, + fetchOptions?: RequestInit +}): Promise { + const { url, headers, fetchOptions } = params + const response = await propfind({ + url, + props: { + [`${DAVNamespaceShort.CALDAV}:calendar-description`]: {}, + [`${DAVNamespaceShort.CALDAV}:calendar-timezone`]: {}, + [`${DAVNamespaceShort.DAV}:displayname`]: {}, + [`${DAVNamespaceShort.CALDAV_APPLE}:calendar-color`]: {}, + [`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {}, + [`${DAVNamespaceShort.DAV}:resourcetype`]: {}, + [`${DAVNamespaceShort.CALDAV}:supported-calendar-component-set`]: {}, + [`${DAVNamespaceShort.DAV}:sync-token`]: {}, + }, + headers, + fetchOptions, + }) + const rs = response[0] + if (!rs.ok) { + throw new Error(`Calendar ${url} does not exists. ${rs.status} ${rs.statusText}`) + } + if (Object.keys(rs.props?.resourceType ?? {}).includes('calendar')) { + throw new Error(`${url} is not a ${rs.props?.resourceType} and not a calendar`) + } + const description = rs.props?.calendarDescription + const timezone = rs.props?.calendarTimezone + return { + description: typeof description === 'string' ? description : '', + timezone: typeof timezone === 'string' ? timezone : '', + url: params.url, + ctag: rs.props?.getctag, + calendarColor: rs.props?.calendarColor, + displayName: rs.props?.displayname._cdata ?? rs.props?.displayname, + components: Array.isArray(rs.props?.supportedCalendarComponentSet.comp) + // NOTE - CJ - 2025-07-03 - comp represents an list of XML nodes in the DAVResponse format + // sc could be `` + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? rs.props?.supportedCalendarComponentSet.comp.map((sc: any) => sc._attributes.name) + : [rs.props?.supportedCalendarComponentSet.comp?._attributes.name], + resourcetype: Object.keys(rs.props?.resourcetype), + syncToken: rs.props?.syncToken, + } +} + +export async function fetchAddressBooks(source: ServerSource | AddressBookSource): Promise { + if (isServerSource(source)) { + const account = await createAccount({ + account: { serverUrl: source.serverUrl, accountType: 'caldav' }, + headers: source.headers, + fetchOptions: source.fetchOptions, + }) + const books = await davFetchAddressBooks({ account, headers: source.headers, fetchOptions: source.fetchOptions }) + return books.map(book => ({ ...book, headers: source.headers, fetchOptions: source.fetchOptions })) + } else { + const book = await davFetchAddressBook({ + url: source.addressBookUrl, + headers: source.headers, + fetchOptions: source.fetchOptions, + }) + return [{ ...book, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.addressBookUid }] + } +} + + +// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/addressBook.ts#L73 +async function davFetchAddressBook(params: { + url: string, + headers?: Record, + fetchOptions?: RequestInit +}): Promise { + const { url, headers, fetchOptions } = params + const response = await propfind({ + url, + props: { + [`${DAVNamespaceShort.DAV}:displayname`]: {}, + [`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {}, + [`${DAVNamespaceShort.DAV}:resourcetype`]: {}, + [`${DAVNamespaceShort.DAV}:sync-token`]: {}, + }, + headers, + fetchOptions, + }) + const rs = response[0] + if (!rs.ok) { + throw new Error(`Address book ${url} does not exists. ${rs.status} ${rs.statusText}`) + } + if (Object.keys(rs.props?.resourceType ?? {}).includes('addressbook')) { + throw new Error(`${url} is not a ${rs.props?.resourceType} and not an addressbook`) + } + const displayName = rs.props?.displayname?._cdata ?? rs.props?.displayname + return { + url: url, + ctag: rs.props?.getctag, + displayName: typeof displayName === 'string' ? displayName : '', + resourcetype: Object.keys(rs.props?.resourcetype), + syncToken: rs.props?.syncToken, + } +} + +export async function fetchAddressBookObjects(addressBook: AddressBook): Promise { + const davVCards = await davFetchVCards({ + addressBook: addressBook, + headers: addressBook.headers, + fetchOptions: addressBook.fetchOptions, + }) + return davVCards.map(o => ({ + url: o.url, + etag: o.etag, + data: new ICAL.Component(ICAL.parse(o.data)), + addressBookUrl: addressBook.url, + })) +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts new file mode 100644 index 0000000..1ce9bc8 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts @@ -0,0 +1,667 @@ +/** + * Event Calendar Helper Functions + * + * Utility functions for working with EventCalendar (vkurko/calendar) format. + * These are standalone helpers that don't require the full adapter. + * + * @see https://github.com/vkurko/calendar + */ + +import type { IcsEvent, IcsDateObject, IcsRecurrenceRule } from 'ts-ics' +import type { + EventCalendarEvent, + EventCalendarEventInput, + EventCalendarDuration, + EventCalendarDurationInput, + EventCalendarView, + EventCalendarResource, +} from '../types/event-calendar' +import type { CalDavAttendee } from '../types/caldav-service' + +// ============================================================================ +// Date/Time Helpers +// ============================================================================ + +/** + * Format a date for EventCalendar (ISO string or Date object) + */ +export function formatEventCalendarDate(date: Date | string): string { + if (typeof date === 'string') return date + return date.toISOString() +} + +/** + * Parse an EventCalendar date to a JavaScript Date + */ +export function parseEventCalendarDate(date: Date | string): Date { + if (date instanceof Date) return date + return new Date(date) +} + +/** + * Check if two dates are on the same day + */ +export function isSameDay(date1: Date | string, date2: Date | string): boolean { + const d1 = parseEventCalendarDate(date1) + const d2 = parseEventCalendarDate(date2) + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ) +} + +/** + * Get the start of day for a date + */ +export function startOfDay(date: Date | string): Date { + const d = parseEventCalendarDate(date) + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0) +} + +/** + * Get the end of day for a date + */ +export function endOfDay(date: Date | string): Date { + const d = parseEventCalendarDate(date) + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999) +} + +/** + * Get the start of week for a date (configurable first day) + */ +export function startOfWeek(date: Date | string, firstDay: number = 1): Date { + const d = parseEventCalendarDate(date) + const day = d.getDay() + const diff = (day < firstDay ? 7 : 0) + day - firstDay + d.setDate(d.getDate() - diff) + return startOfDay(d) +} + +/** + * Get the end of week for a date + */ +export function endOfWeek(date: Date | string, firstDay: number = 1): Date { + const start = startOfWeek(date, firstDay) + start.setDate(start.getDate() + 6) + return endOfDay(start) +} + +/** + * Get the start of month for a date + */ +export function startOfMonth(date: Date | string): Date { + const d = parseEventCalendarDate(date) + return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0) +} + +/** + * Get the end of month for a date + */ +export function endOfMonth(date: Date | string): Date { + const d = parseEventCalendarDate(date) + return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999) +} + +// ============================================================================ +// Duration Helpers +// ============================================================================ + +/** + * Parse duration input to EventCalendarDuration + */ +export function parseDuration(input: EventCalendarDurationInput): EventCalendarDuration { + if (typeof input === 'number') { + // Input is total seconds + return secondsToDuration(input) + } + + if (typeof input === 'string') { + // Input is 'hh:mm:ss' or 'hh:mm' format + const parts = input.split(':').map(Number) + if (parts.length === 2) { + return { hours: parts[0], minutes: parts[1] } + } else if (parts.length === 3) { + return { hours: parts[0], minutes: parts[1], seconds: parts[2] } + } + return {} + } + + // Input is already an object + return input +} + +/** + * Convert duration to total seconds + */ +export function 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 duration object + */ +export function 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 +} + +/** + * Format duration as 'hh:mm:ss' string + */ +export function formatDuration(duration: EventCalendarDuration): string { + const totalSeconds = durationToSeconds(duration) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + const pad = (n: number) => n.toString().padStart(2, '0') + + if (seconds > 0) { + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + } + return `${pad(hours)}:${pad(minutes)}` +} + +/** + * Add duration to a date + */ +export function addDuration(date: Date | string, duration: EventCalendarDuration): Date { + const result = new Date(parseEventCalendarDate(date)) + const seconds = durationToSeconds(duration) + result.setTime(result.getTime() + seconds * 1000) + return result +} + +/** + * Subtract duration from a date + */ +export function subtractDuration(date: Date | string, duration: EventCalendarDuration): Date { + const result = new Date(parseEventCalendarDate(date)) + const seconds = durationToSeconds(duration) + result.setTime(result.getTime() - seconds * 1000) + return result +} + +// ============================================================================ +// Event Helpers +// ============================================================================ + +/** + * Check if an event is an all-day event + */ +export function isAllDayEvent(event: EventCalendarEvent | EventCalendarEventInput): boolean { + return event.allDay === true +} + +/** + * Check if an event spans multiple days + */ +export function isMultiDayEvent(event: EventCalendarEvent | EventCalendarEventInput): boolean { + const start = parseEventCalendarDate(event.start) + const end = event.end ? parseEventCalendarDate(event.end) : start + return !isSameDay(start, end) +} + +/** + * Get the duration of an event in minutes + */ +export function getEventDurationMinutes(event: EventCalendarEvent | EventCalendarEventInput): number { + const start = parseEventCalendarDate(event.start) + const end = event.end ? parseEventCalendarDate(event.end) : start + return Math.round((end.getTime() - start.getTime()) / (1000 * 60)) +} + +/** + * Check if an event overlaps with a time range + */ +export function eventOverlapsRange( + event: EventCalendarEvent | EventCalendarEventInput, + rangeStart: Date | string, + rangeEnd: Date | string +): boolean { + const eventStart = parseEventCalendarDate(event.start) + const eventEnd = event.end ? parseEventCalendarDate(event.end) : eventStart + const start = parseEventCalendarDate(rangeStart) + const end = parseEventCalendarDate(rangeEnd) + + return eventStart < end && eventEnd > start +} + +/** + * Filter events that overlap with a time range + */ +export function filterEventsInRange( + events: EventCalendarEvent[], + rangeStart: Date | string, + rangeEnd: Date | string +): EventCalendarEvent[] { + return events.filter((event) => eventOverlapsRange(event, rangeStart, rangeEnd)) +} + +/** + * Sort events by start date + */ +export function sortEventsByStart(events: EventCalendarEvent[]): EventCalendarEvent[] { + return [...events].sort((a, b) => { + const aStart = parseEventCalendarDate(a.start) + const bStart = parseEventCalendarDate(b.start) + return aStart.getTime() - bStart.getTime() + }) +} + +/** + * Group events by date + */ +export function groupEventsByDate(events: EventCalendarEvent[]): Map { + const groups = new Map() + + for (const event of events) { + const dateKey = startOfDay(event.start).toISOString().split('T')[0] + const existing = groups.get(dateKey) ?? [] + existing.push(event) + groups.set(dateKey, existing) + } + + return groups +} + +/** + * Create a new event with updated times + */ +export function moveEvent( + event: EventCalendarEvent, + newStart: Date | string, + newEnd?: Date | string +): EventCalendarEvent { + const start = parseEventCalendarDate(newStart) + const originalStart = parseEventCalendarDate(event.start) + const originalEnd = event.end ? parseEventCalendarDate(event.end) : originalStart + + // Calculate original duration + const duration = originalEnd.getTime() - originalStart.getTime() + + // Calculate new end if not provided + const end = newEnd ? parseEventCalendarDate(newEnd) : new Date(start.getTime() + duration) + + return { + ...event, + start, + end, + } +} + +/** + * Resize an event by changing its end time + */ +export function resizeEvent( + event: EventCalendarEvent, + newEnd: Date | string +): EventCalendarEvent { + return { + ...event, + end: parseEventCalendarDate(newEnd), + } +} + +// ============================================================================ +// View Helpers +// ============================================================================ + +/** + * Get the date range for a view + */ +export function getViewDateRange( + view: EventCalendarView, + currentDate: Date | string +): { start: Date; end: Date } { + const date = parseEventCalendarDate(currentDate) + + switch (view) { + case 'dayGridDay': + case 'timeGridDay': + case 'listDay': + case 'resourceTimeGridDay': + case 'resourceTimelineDay': + return { start: startOfDay(date), end: endOfDay(date) } + + case 'dayGridWeek': + case 'timeGridWeek': + case 'listWeek': + case 'resourceTimeGridWeek': + case 'resourceTimelineWeek': + return { start: startOfWeek(date), end: endOfWeek(date) } + + case 'dayGridMonth': + case 'listMonth': + case 'resourceTimelineMonth': + return { start: startOfMonth(date), end: endOfMonth(date) } + + case 'listYear': + return { + start: new Date(date.getFullYear(), 0, 1), + end: new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999), + } + + default: + return { start: startOfWeek(date), end: endOfWeek(date) } + } +} + +/** + * Check if a view is a list view + */ +export function isListView(view: EventCalendarView): boolean { + return view.startsWith('list') +} + +/** + * Check if a view is a resource view + */ +export function isResourceView(view: EventCalendarView): boolean { + return view.startsWith('resource') +} + +/** + * Check if a view is a timeline view + */ +export function isTimelineView(view: EventCalendarView): boolean { + return view.toLowerCase().includes('timeline') +} + +// ============================================================================ +// Resource Helpers +// ============================================================================ + +/** + * Find a resource by ID + */ +export function findResourceById( + resources: EventCalendarResource[], + id: string | number +): EventCalendarResource | undefined { + for (const resource of resources) { + if (resource.id === id) return resource + if (resource.children) { + const found = findResourceById(resource.children, id) + if (found) return found + } + } + return undefined +} + +/** + * Flatten nested resources + */ +export function flattenResources(resources: EventCalendarResource[]): EventCalendarResource[] { + const result: EventCalendarResource[] = [] + + for (const resource of resources) { + result.push(resource) + if (resource.children) { + result.push(...flattenResources(resource.children)) + } + } + + return result +} + +/** + * Get events for a specific resource + */ +export function getEventsForResource( + events: EventCalendarEvent[], + resourceId: string | number +): EventCalendarEvent[] { + return events.filter((event) => event.resourceIds?.includes(resourceId)) +} + +// ============================================================================ +// ICS Conversion Helpers +// ============================================================================ + +/** + * Convert IcsDateObject to JavaScript Date + */ +export function icsDateToJsDate(icsDate: IcsDateObject): Date { + if (icsDate.local?.date) { + return icsDate.local.date + } + return icsDate.date +} + +/** + * Convert JavaScript Date to IcsDateObject + */ +export function jsDateToIcsDate(date: Date, allDay: boolean = false, timezone?: string): IcsDateObject { + if (allDay) { + return { + type: 'DATE', + date, + } + } + + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date, + } + + if (timezone) { + icsDate.local = { + date, + timezone, + tzoffset: '+0000', // Default, will be recalculated when needed + } + } + + return icsDate +} + +/** + * Check if an IcsEvent is an all-day event + */ +export function isIcsEventAllDay(event: IcsEvent): boolean { + return event.start.type === 'DATE' +} + +/** + * Get the timezone from an IcsEvent + */ +export function getIcsEventTimezone(event: IcsEvent): string | undefined { + return event.start.local?.timezone +} + +// ============================================================================ +// Recurrence Helpers +// ============================================================================ + +/** + * Check if an event has a recurrence rule + */ +export function hasRecurrence(event: IcsEvent): boolean { + return !!event.recurrenceRule +} + +/** + * Check if an event is a recurring instance (has recurrenceId) + */ +export function isRecurringInstance(event: IcsEvent): boolean { + return !!event.recurrenceId +} + +/** + * Get a human-readable description of recurrence rule + */ +export function describeRecurrence(rule: IcsRecurrenceRule): string { + const parts: string[] = [] + + // Frequency + const freqMap: Record = { + DAILY: 'day', + WEEKLY: 'week', + MONTHLY: 'month', + YEARLY: 'year', + } + const freq = freqMap[rule.frequency] ?? rule.frequency.toLowerCase() + + // Interval + const interval = rule.interval ?? 1 + if (interval === 1) { + parts.push(`Every ${freq}`) + } else { + parts.push(`Every ${interval} ${freq}s`) + } + + // Days of week + if (rule.byDay && rule.byDay.length > 0) { + const dayMap: Record = { + SU: 'Sunday', + MO: 'Monday', + TU: 'Tuesday', + WE: 'Wednesday', + TH: 'Thursday', + FR: 'Friday', + SA: 'Saturday', + } + const days = rule.byDay.map((d) => { + const dayCode = typeof d === 'string' ? d : d.day + return dayMap[dayCode] ?? dayCode + }) + parts.push(`on ${days.join(', ')}`) + } + + // Count + if (rule.count) { + parts.push(`${rule.count} times`) + } + + // Until + if (rule.until) { + const untilDate = rule.until instanceof Date ? rule.until : new Date(rule.until.date) + parts.push(`until ${untilDate.toLocaleDateString()}`) + } + + return parts.join(' ') +} + +// ============================================================================ +// Color Helpers +// ============================================================================ + +/** + * Generate a color from a string (for consistent calendar colors) + */ +export function stringToColor(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + + const h = hash % 360 + return `hsl(${h}, 65%, 50%)` +} + +/** + * Check if a color is dark (for text contrast) + */ +export function isColorDark(color: string): boolean { + // Convert hex to RGB + let r: number, g: number, b: number + + if (color.startsWith('#')) { + const hex = color.slice(1) + if (hex.length === 3) { + r = parseInt(hex[0] + hex[0], 16) + g = parseInt(hex[1] + hex[1], 16) + b = parseInt(hex[2] + hex[2], 16) + } else { + r = parseInt(hex.slice(0, 2), 16) + g = parseInt(hex.slice(2, 4), 16) + b = parseInt(hex.slice(4, 6), 16) + } + } else if (color.startsWith('rgb')) { + const match = color.match(/\d+/g) + if (!match) return false + r = parseInt(match[0]) + g = parseInt(match[1]) + b = parseInt(match[2]) + } else { + return false + } + + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return luminance < 0.5 +} + +/** + * Get contrasting text color (black or white) + */ +export function getContrastingTextColor(backgroundColor: string): string { + return isColorDark(backgroundColor) ? '#ffffff' : '#000000' +} + +// ============================================================================ +// Attendee Helpers +// ============================================================================ + +/** + * Get display name for an attendee + */ +export function getAttendeeDisplayName(attendee: CalDavAttendee): string { + return attendee.name ?? attendee.email +} + +/** + * Get status icon for an attendee + */ +export function getAttendeeStatusIcon(status?: CalDavAttendee['partstat']): string { + switch (status) { + case 'ACCEPTED': + return '✓' + case 'DECLINED': + return '✗' + case 'TENTATIVE': + return '?' + case 'NEEDS-ACTION': + default: + return '○' + } +} + +/** + * Get status color for an attendee + */ +export function getAttendeeStatusColor(status?: CalDavAttendee['partstat']): string { + switch (status) { + case 'ACCEPTED': + return '#22c55e' // green + case 'DECLINED': + return '#ef4444' // red + case 'TENTATIVE': + return '#f59e0b' // amber + case 'NEEDS-ACTION': + default: + return '#6b7280' // gray + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ical-timezones.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ical-timezones.ts new file mode 100644 index 0000000..ae8f36a --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ical-timezones.ts @@ -0,0 +1,1027 @@ +/** + * IANA Timezone VTIMEZONE definitions for iCalendar + * + * This module provides VTIMEZONE blocks for common IANA timezones. + * It replaces the 'timezones-ical-library' package which is not compatible with Node 22. + * + * Data sourced from IANA Time Zone Database (tzdata) + */ + +// VTIMEZONE definitions for common timezones +// Format: TZID -> VTIMEZONE block (without BEGIN:VTIMEZONE and END:VTIMEZONE wrappers) +const TIMEZONE_DATA: Record = { + // UTC + UTC: `BEGIN:VTIMEZONE +TZID:UTC +X-LIC-LOCATION:UTC +BEGIN:STANDARD +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +TZNAME:UTC +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Europe + 'Europe/Paris': `BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/London': `BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:DAYLIGHT +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +TZNAME:BST +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +TZNAME:GMT +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Berlin': `BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Rome': `BEGIN:VTIMEZONE +TZID:Europe/Rome +X-LIC-LOCATION:Europe/Rome +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Madrid': `BEGIN:VTIMEZONE +TZID:Europe/Madrid +X-LIC-LOCATION:Europe/Madrid +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Amsterdam': `BEGIN:VTIMEZONE +TZID:Europe/Amsterdam +X-LIC-LOCATION:Europe/Amsterdam +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Brussels': `BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Vienna': `BEGIN:VTIMEZONE +TZID:Europe/Vienna +X-LIC-LOCATION:Europe/Vienna +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Warsaw': `BEGIN:VTIMEZONE +TZID:Europe/Warsaw +X-LIC-LOCATION:Europe/Warsaw +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Prague': `BEGIN:VTIMEZONE +TZID:Europe/Prague +X-LIC-LOCATION:Europe/Prague +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Budapest': `BEGIN:VTIMEZONE +TZID:Europe/Budapest +X-LIC-LOCATION:Europe/Budapest +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Athens': `BEGIN:VTIMEZONE +TZID:Europe/Athens +X-LIC-LOCATION:Europe/Athens +BEGIN:DAYLIGHT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +TZNAME:EEST +DTSTART:19700329T030000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19701025T040000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Helsinki': `BEGIN:VTIMEZONE +TZID:Europe/Helsinki +X-LIC-LOCATION:Europe/Helsinki +BEGIN:DAYLIGHT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +TZNAME:EEST +DTSTART:19700329T030000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19701025T040000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Stockholm': `BEGIN:VTIMEZONE +TZID:Europe/Stockholm +X-LIC-LOCATION:Europe/Stockholm +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Oslo': `BEGIN:VTIMEZONE +TZID:Europe/Oslo +X-LIC-LOCATION:Europe/Oslo +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Copenhagen': `BEGIN:VTIMEZONE +TZID:Europe/Copenhagen +X-LIC-LOCATION:Europe/Copenhagen +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Dublin': `BEGIN:VTIMEZONE +TZID:Europe/Dublin +X-LIC-LOCATION:Europe/Dublin +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +TZNAME:GMT +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +TZNAME:IST +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`, + + 'Europe/Lisbon': `BEGIN:VTIMEZONE +TZID:Europe/Lisbon +X-LIC-LOCATION:Europe/Lisbon +BEGIN:DAYLIGHT +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +TZNAME:WEST +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +TZNAME:WET +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Zurich': `BEGIN:VTIMEZONE +TZID:Europe/Zurich +X-LIC-LOCATION:Europe/Zurich +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE`, + + 'Europe/Moscow': `BEGIN:VTIMEZONE +TZID:Europe/Moscow +X-LIC-LOCATION:Europe/Moscow +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0300 +TZNAME:MSK +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Americas + 'America/New_York': `BEGIN:VTIMEZONE +TZID:America/New_York +X-LIC-LOCATION:America/New_York +BEGIN:DAYLIGHT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Los_Angeles': `BEGIN:VTIMEZONE +TZID:America/Los_Angeles +X-LIC-LOCATION:America/Los_Angeles +BEGIN:DAYLIGHT +TZOFFSETFROM:-0800 +TZOFFSETTO:-0700 +TZNAME:PDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0700 +TZOFFSETTO:-0800 +TZNAME:PST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Chicago': `BEGIN:VTIMEZONE +TZID:America/Chicago +X-LIC-LOCATION:America/Chicago +BEGIN:DAYLIGHT +TZOFFSETFROM:-0600 +TZOFFSETTO:-0500 +TZNAME:CDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0500 +TZOFFSETTO:-0600 +TZNAME:CST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Denver': `BEGIN:VTIMEZONE +TZID:America/Denver +X-LIC-LOCATION:America/Denver +BEGIN:DAYLIGHT +TZOFFSETFROM:-0700 +TZOFFSETTO:-0600 +TZNAME:MDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0600 +TZOFFSETTO:-0700 +TZNAME:MST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Toronto': `BEGIN:VTIMEZONE +TZID:America/Toronto +X-LIC-LOCATION:America/Toronto +BEGIN:DAYLIGHT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Vancouver': `BEGIN:VTIMEZONE +TZID:America/Vancouver +X-LIC-LOCATION:America/Vancouver +BEGIN:DAYLIGHT +TZOFFSETFROM:-0800 +TZOFFSETTO:-0700 +TZNAME:PDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0700 +TZOFFSETTO:-0800 +TZNAME:PST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'America/Mexico_City': `BEGIN:VTIMEZONE +TZID:America/Mexico_City +X-LIC-LOCATION:America/Mexico_City +BEGIN:STANDARD +TZOFFSETFROM:-0600 +TZOFFSETTO:-0600 +TZNAME:CST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'America/Sao_Paulo': `BEGIN:VTIMEZONE +TZID:America/Sao_Paulo +X-LIC-LOCATION:America/Sao_Paulo +BEGIN:STANDARD +TZOFFSETFROM:-0300 +TZOFFSETTO:-0300 +TZNAME:-03 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'America/Buenos_Aires': `BEGIN:VTIMEZONE +TZID:America/Buenos_Aires +X-LIC-LOCATION:America/Buenos_Aires +BEGIN:STANDARD +TZOFFSETFROM:-0300 +TZOFFSETTO:-0300 +TZNAME:-03 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Asia + 'Asia/Tokyo': `BEGIN:VTIMEZONE +TZID:Asia/Tokyo +X-LIC-LOCATION:Asia/Tokyo +BEGIN:STANDARD +TZOFFSETFROM:+0900 +TZOFFSETTO:+0900 +TZNAME:JST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Shanghai': `BEGIN:VTIMEZONE +TZID:Asia/Shanghai +X-LIC-LOCATION:Asia/Shanghai +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:CST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Hong_Kong': `BEGIN:VTIMEZONE +TZID:Asia/Hong_Kong +X-LIC-LOCATION:Asia/Hong_Kong +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:HKT +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Singapore': `BEGIN:VTIMEZONE +TZID:Asia/Singapore +X-LIC-LOCATION:Asia/Singapore +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:+08 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Seoul': `BEGIN:VTIMEZONE +TZID:Asia/Seoul +X-LIC-LOCATION:Asia/Seoul +BEGIN:STANDARD +TZOFFSETFROM:+0900 +TZOFFSETTO:+0900 +TZNAME:KST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Bangkok': `BEGIN:VTIMEZONE +TZID:Asia/Bangkok +X-LIC-LOCATION:Asia/Bangkok +BEGIN:STANDARD +TZOFFSETFROM:+0700 +TZOFFSETTO:+0700 +TZNAME:+07 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Dubai': `BEGIN:VTIMEZONE +TZID:Asia/Dubai +X-LIC-LOCATION:Asia/Dubai +BEGIN:STANDARD +TZOFFSETFROM:+0400 +TZOFFSETTO:+0400 +TZNAME:+04 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Kolkata': `BEGIN:VTIMEZONE +TZID:Asia/Kolkata +X-LIC-LOCATION:Asia/Kolkata +BEGIN:STANDARD +TZOFFSETFROM:+0530 +TZOFFSETTO:+0530 +TZNAME:IST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Jakarta': `BEGIN:VTIMEZONE +TZID:Asia/Jakarta +X-LIC-LOCATION:Asia/Jakarta +BEGIN:STANDARD +TZOFFSETFROM:+0700 +TZOFFSETTO:+0700 +TZNAME:WIB +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Asia/Manila': `BEGIN:VTIMEZONE +TZID:Asia/Manila +X-LIC-LOCATION:Asia/Manila +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:PST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Australia + 'Australia/Sydney': `BEGIN:VTIMEZONE +TZID:Australia/Sydney +X-LIC-LOCATION:Australia/Sydney +BEGIN:STANDARD +TZOFFSETFROM:+1100 +TZOFFSETTO:+1000 +TZNAME:AEST +DTSTART:19700405T030000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+1000 +TZOFFSETTO:+1100 +TZNAME:AEDT +DTSTART:19701004T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU +END:DAYLIGHT +END:VTIMEZONE`, + + 'Australia/Melbourne': `BEGIN:VTIMEZONE +TZID:Australia/Melbourne +X-LIC-LOCATION:Australia/Melbourne +BEGIN:STANDARD +TZOFFSETFROM:+1100 +TZOFFSETTO:+1000 +TZNAME:AEST +DTSTART:19700405T030000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+1000 +TZOFFSETTO:+1100 +TZNAME:AEDT +DTSTART:19701004T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU +END:DAYLIGHT +END:VTIMEZONE`, + + 'Australia/Perth': `BEGIN:VTIMEZONE +TZID:Australia/Perth +X-LIC-LOCATION:Australia/Perth +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:AWST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Africa + 'Africa/Cairo': `BEGIN:VTIMEZONE +TZID:Africa/Cairo +X-LIC-LOCATION:Africa/Cairo +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Africa/Johannesburg': `BEGIN:VTIMEZONE +TZID:Africa/Johannesburg +X-LIC-LOCATION:Africa/Johannesburg +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +TZNAME:SAST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Africa/Lagos': `BEGIN:VTIMEZONE +TZID:Africa/Lagos +X-LIC-LOCATION:Africa/Lagos +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +TZNAME:WAT +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Africa/Casablanca': `BEGIN:VTIMEZONE +TZID:Africa/Casablanca +X-LIC-LOCATION:Africa/Casablanca +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +TZNAME:+01 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Pacific + 'Pacific/Auckland': `BEGIN:VTIMEZONE +TZID:Pacific/Auckland +X-LIC-LOCATION:Pacific/Auckland +BEGIN:DAYLIGHT +TZOFFSETFROM:+1200 +TZOFFSETTO:+1300 +TZNAME:NZDT +DTSTART:19700927T020000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+1300 +TZOFFSETTO:+1200 +TZNAME:NZST +DTSTART:19700405T030000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, + + 'Pacific/Honolulu': `BEGIN:VTIMEZONE +TZID:Pacific/Honolulu +X-LIC-LOCATION:Pacific/Honolulu +BEGIN:STANDARD +TZOFFSETFROM:-1000 +TZOFFSETTO:-1000 +TZNAME:HST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Indian Ocean + 'Indian/Mauritius': `BEGIN:VTIMEZONE +TZID:Indian/Mauritius +X-LIC-LOCATION:Indian/Mauritius +BEGIN:STANDARD +TZOFFSETFROM:+0400 +TZOFFSETTO:+0400 +TZNAME:+04 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // Atlantic + 'Atlantic/Reykjavik': `BEGIN:VTIMEZONE +TZID:Atlantic/Reykjavik +X-LIC-LOCATION:Atlantic/Reykjavik +BEGIN:STANDARD +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +TZNAME:GMT +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + // French Overseas Territories + 'America/Martinique': `BEGIN:VTIMEZONE +TZID:America/Martinique +X-LIC-LOCATION:America/Martinique +BEGIN:STANDARD +TZOFFSETFROM:-0400 +TZOFFSETTO:-0400 +TZNAME:AST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'America/Guadeloupe': `BEGIN:VTIMEZONE +TZID:America/Guadeloupe +X-LIC-LOCATION:America/Guadeloupe +BEGIN:STANDARD +TZOFFSETFROM:-0400 +TZOFFSETTO:-0400 +TZNAME:AST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'America/Cayenne': `BEGIN:VTIMEZONE +TZID:America/Cayenne +X-LIC-LOCATION:America/Cayenne +BEGIN:STANDARD +TZOFFSETFROM:-0300 +TZOFFSETTO:-0300 +TZNAME:-03 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Indian/Reunion': `BEGIN:VTIMEZONE +TZID:Indian/Reunion +X-LIC-LOCATION:Indian/Reunion +BEGIN:STANDARD +TZOFFSETFROM:+0400 +TZOFFSETTO:+0400 +TZNAME:+04 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Indian/Mayotte': `BEGIN:VTIMEZONE +TZID:Indian/Mayotte +X-LIC-LOCATION:Indian/Mayotte +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0300 +TZNAME:EAT +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Pacific/Noumea': `BEGIN:VTIMEZONE +TZID:Pacific/Noumea +X-LIC-LOCATION:Pacific/Noumea +BEGIN:STANDARD +TZOFFSETFROM:+1100 +TZOFFSETTO:+1100 +TZNAME:+11 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Pacific/Tahiti': `BEGIN:VTIMEZONE +TZID:Pacific/Tahiti +X-LIC-LOCATION:Pacific/Tahiti +BEGIN:STANDARD +TZOFFSETFROM:-1000 +TZOFFSETTO:-1000 +TZNAME:-10 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'Pacific/Wallis': `BEGIN:VTIMEZONE +TZID:Pacific/Wallis +X-LIC-LOCATION:Pacific/Wallis +BEGIN:STANDARD +TZOFFSETFROM:+1200 +TZOFFSETTO:+1200 +TZNAME:+12 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE`, + + 'America/Miquelon': `BEGIN:VTIMEZONE +TZID:America/Miquelon +X-LIC-LOCATION:America/Miquelon +BEGIN:DAYLIGHT +TZOFFSETFROM:-0300 +TZOFFSETTO:-0200 +TZNAME:-02 +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0200 +TZOFFSETTO:-0300 +TZNAME:-03 +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE`, +} + +// Timezone aliases (link common aliases to their canonical names) +const TIMEZONE_ALIASES: Record = { + 'Etc/UTC': 'UTC', + 'Etc/GMT': 'UTC', + GMT: 'UTC', + 'Europe/Luxembourg': 'Europe/Brussels', + 'Europe/Monaco': 'Europe/Paris', + 'Europe/Vatican': 'Europe/Rome', + 'Europe/San_Marino': 'Europe/Rome', + 'Europe/Bratislava': 'Europe/Prague', + 'Europe/Ljubljana': 'Europe/Budapest', + 'Europe/Zagreb': 'Europe/Budapest', + 'Europe/Sarajevo': 'Europe/Budapest', + 'Europe/Skopje': 'Europe/Budapest', + 'Europe/Podgorica': 'Europe/Budapest', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Saigon': 'Asia/Bangkok', + 'Australia/Brisbane': 'Australia/Sydney', + 'Australia/Canberra': 'Australia/Sydney', + 'Australia/Hobart': 'Australia/Sydney', + 'Australia/Adelaide': 'Australia/Sydney', + 'Australia/Darwin': 'Australia/Perth', + 'America/Montreal': 'America/Toronto', + 'America/Indianapolis': 'America/New_York', + 'America/Phoenix': 'America/Denver', + 'America/Anchorage': 'America/Los_Angeles', + 'US/Eastern': 'America/New_York', + 'US/Pacific': 'America/Los_Angeles', + 'US/Central': 'America/Chicago', + 'US/Mountain': 'America/Denver', +} + +/** + * Get the VTIMEZONE block for a given IANA timezone ID + * + * This function replaces tzlib_get_ical_block from 'timezones-ical-library' + * + * @param tzid - The IANA timezone identifier (e.g., 'Europe/Paris', 'America/New_York') + * @returns An array containing the VTIMEZONE block string, or an empty array if not found + * + * @example + * const block = getIcalTimezoneBlock('Europe/Paris') + * // Returns: ['BEGIN:VTIMEZONE\nTZID:Europe/Paris\n...END:VTIMEZONE'] + */ +export function getIcalTimezoneBlock(tzid: string): [string] | [] { + // Resolve alias to canonical name + const canonicalTzid = TIMEZONE_ALIASES[tzid] ?? tzid + + const block = TIMEZONE_DATA[canonicalTzid] + if (block) { + // If we used an alias, update the TZID in the block + if (canonicalTzid !== tzid) { + return [block.replace(`TZID:${canonicalTzid}`, `TZID:${tzid}`)] + } + return [block] + } + + // Fallback: generate a simple VTIMEZONE for unknown timezones + // This uses the browser's Intl API to get the UTC offset + try { + const now = new Date() + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: tzid, + timeZoneName: 'shortOffset', + }) + const parts = formatter.formatToParts(now) + const offsetPart = parts.find((p) => p.type === 'timeZoneName') + const offsetStr = offsetPart?.value ?? 'UTC' + + // Parse offset like "GMT+2" or "GMT-5:30" + const offsetMatch = offsetStr.match(/GMT([+-]?)(\d{1,2})(?::(\d{2}))?/) + let offsetFormatted = '+0000' + if (offsetMatch) { + const sign = offsetMatch[1] || '+' + const hours = offsetMatch[2].padStart(2, '0') + const minutes = offsetMatch[3] || '00' + offsetFormatted = `${sign}${hours}${minutes}` + } + + const fallbackBlock = `BEGIN:VTIMEZONE +TZID:${tzid} +X-LIC-LOCATION:${tzid} +BEGIN:STANDARD +TZOFFSETFROM:${offsetFormatted} +TZOFFSETTO:${offsetFormatted} +TZNAME:${offsetStr} +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE` + + console.warn(`[ical-timezones] Using fallback VTIMEZONE for unknown timezone: ${tzid}`) + return [fallbackBlock] + } catch { + console.error(`[ical-timezones] Failed to generate VTIMEZONE for timezone: ${tzid}`) + return [] + } +} + +/** + * Check if a timezone is supported + */ +export function isTimezoneSupported(tzid: string): boolean { + const canonicalTzid = TIMEZONE_ALIASES[tzid] ?? tzid + return canonicalTzid in TIMEZONE_DATA +} + +/** + * Get list of all supported timezone IDs + */ +export function getSupportedTimezones(): string[] { + return Object.keys(TIMEZONE_DATA) +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ics-helper.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ics-helper.ts new file mode 100644 index 0000000..8b71f9f --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/ics-helper.ts @@ -0,0 +1,5 @@ +import type { IcsEvent } from 'ts-ics' + +export function isEventAllDay(event: IcsEvent) { + return event.start.type === 'DATE' || event.end?.type === 'DATE' +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/index.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/index.ts new file mode 100644 index 0000000..d3828de --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './dav-helper' +export * from './ics-helper' +export * from './types-helper' diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/types-helper.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/types-helper.ts new file mode 100644 index 0000000..0b84d5f --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/types-helper.ts @@ -0,0 +1,22 @@ +import type { CalendarOptions, + SelectCalendarHandlers, + EventEditHandlers, + ServerSource, + VCardProvider, +} from '../types/options' + +export function isServerSource(source: ServerSource | unknown): source is ServerSource { + return (source as ServerSource).serverUrl !== undefined +} + +export function isVCardProvider(source: VCardProvider | unknown): source is VCardProvider { + return (source as VCardProvider).fetchContacts !== undefined +} + +export function hasEventHandlers(options: CalendarOptions): options is EventEditHandlers { + return (options as EventEditHandlers).onCreateEvent !== undefined +} + +export function hasCalendarHandlers(options: CalendarOptions): options is SelectCalendarHandlers { + return (options as SelectCalendarHandlers).onClickSelectCalendars !== undefined +}