From 250e852762cf36800a7aa719257b98a298ad247c Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:33:40 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20CalDAV=20helper=20func?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add helper functions for CalDAV operations including date formatting, property extraction and default values for calendar configuration. Co-Authored-By: Claude Opus 4.5 --- .../calendar/services/dav/caldav-helpers.ts | 418 ++++++++++++++++++ .../calendar/services/dav/constants.ts | 16 + 2 files changed, 434 insertions(+) create mode 100644 src/frontend/apps/calendars/src/features/calendar/services/dav/caldav-helpers.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/services/dav/constants.ts diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/caldav-helpers.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/caldav-helpers.ts new file mode 100644 index 0000000..f6215a3 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/caldav-helpers.ts @@ -0,0 +1,418 @@ +/** + * CalDAV Helper Functions + * + * Factorized utilities for XML building, DAV requests, and error handling. + */ + +import { davRequest, propfind, DAVNamespaceShort } from 'tsdav' +import type { DAVMethods } from 'tsdav' + +type HTTPMethods = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH' +type AllowedMethods = DAVMethods | HTTPMethods +import type { SharePrivilege, CalDavResponse } from './types/caldav-service' + +// ============================================================================ +// XML Helpers +// ============================================================================ + +/** Escape special XML characters */ +export function escapeXml(str: string | undefined | null): string { + if (str === undefined || str === null) { + return ''; + } + if (typeof str !== 'string') { + str = String(str); + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** XML namespaces used in CalDAV */ +export const XML_NS = { + DAV: 'xmlns:D="DAV:"', + CALDAV: 'xmlns:C="urn:ietf:params:xml:ns:caldav"', + APPLE: 'xmlns:A="http://apple.com/ns/ical/"', + CS: 'xmlns:CS="http://calendarserver.org/ns/"', +} as const + +/** Build XML prop element */ +export function xmlProp(namespace: string, name: string, value: string): string { + return `<${namespace}:${name}>${escapeXml(value)}` +} + +/** Build XML with optional value (returns empty string if value is undefined) */ +export function xmlPropOptional(namespace: string, name: string, value: string | undefined): string { + return value !== undefined ? xmlProp(namespace, name, value) : '' +} + +// ============================================================================ +// Calendar Property Builders +// ============================================================================ + +export type CalendarProps = { + displayName?: string + description?: string + color?: string + components?: string[] +} + +/** Build calendar property XML elements */ +export function buildCalendarPropsXml(props: CalendarProps): string[] { + const elements: string[] = [] + + if (props.displayName !== undefined && props.displayName !== null && typeof props.displayName === 'string') { + elements.push(xmlProp('D', 'displayname', props.displayName)) + } + if (props.description !== undefined && props.description !== null && typeof props.description === 'string') { + elements.push(xmlProp('C', 'calendar-description', props.description)) + } + if (props.color !== undefined && props.color !== null && typeof props.color === 'string') { + elements.push(xmlProp('A', 'calendar-color', props.color)) + } + if (props.components && props.components.length > 0) { + const comps = props.components.map((c) => ``).join('') + elements.push(`${comps}`) + } + + return elements +} + +/** Build MKCALENDAR request body */ +export function buildMkCalendarXml(props: CalendarProps): string { + const propsXml = buildCalendarPropsXml(props) + return ` + + + + ${propsXml.join('\n ')} + + +` +} + +/** Build PROPPATCH request body */ +export function buildProppatchXml(props: CalendarProps): string { + const propsXml = buildCalendarPropsXml(props) + return ` + + + + ${propsXml.join('\n ')} + + +` +} + +// ============================================================================ +// Sharing XML Builders +// ============================================================================ + +/** Convert SharePrivilege to XML element */ +export function sharePrivilegeToXml(privilege: SharePrivilege): string { + const map: Record = { + read: '', + 'read-write': '', + 'read-write-noacl': '', + admin: '', + } + return map[privilege] ?? '' +} + +/** Parse access object to SharePrivilege */ +export function parseSharePrivilege(access: unknown): SharePrivilege { + if (!access) return 'read' + const accessObj = access as Record + if (accessObj['read-write']) return 'read-write' + if (accessObj['admin']) return 'admin' + if (accessObj['read-write-noacl']) return 'read-write-noacl' + return 'read' +} + +export type ShareeXmlParams = { + href: string + displayName?: string + privilege: SharePrivilege + summary?: string +} + +/** Build share set XML for a single sharee */ +export function buildShareeSetXml(params: ShareeXmlParams): string { + const privilege = sharePrivilegeToXml(params.privilege) + const commonName = params.displayName + ? `${escapeXml(params.displayName)}` + : '' + const summary = params.summary ? `${escapeXml(params.summary)}` : '' + + return ` + + ${escapeXml(params.href)} + ${commonName} + ${summary} + ${privilege} + ` +} + +/** Build CS:share request body */ +export function buildShareRequestXml(sharees: ShareeXmlParams[]): string { + const shareesXml = sharees.map(buildShareeSetXml).join('') + return ` + + ${shareesXml} +` +} + +/** Build CS:share remove request body */ +export function buildUnshareRequestXml(shareeHref: string): string { + return ` + + + ${escapeXml(shareeHref)} + +` +} + +/** Build invite-reply request body */ +export function buildInviteReplyXml(inReplyTo: string, accept: boolean): string { + return ` + + ${escapeXml(inReplyTo)} + +` +} + +// ============================================================================ +// Sync XML Builders +// ============================================================================ + +export type SyncCollectionParams = { + syncToken: string + syncLevel?: number | 'infinite' +} + +/** Build sync-collection REPORT body */ +export function buildSyncCollectionXml(params: SyncCollectionParams): string { + return ` + + ${escapeXml(params.syncToken)} + ${params.syncLevel ?? 1} + + + + +` +} + +// ============================================================================ +// Principal Search XML Builder +// ============================================================================ + +/** Build principal-property-search REPORT body */ +export function buildPrincipalSearchXml(query: string): string { + return ` + + + + + + ${escapeXml(query)} + + + + + +` +} + +// ============================================================================ +// DAV Request Helpers +// ============================================================================ + +export type DavRequestOptions = { + url: string + method: AllowedMethods + body: string + headers?: Record + fetchOptions?: RequestInit + contentType?: string +} + +/** Execute a DAV request with standard error handling */ +export async function executeDavRequest(options: DavRequestOptions): Promise { + try { + // Use fetch directly for methods that davRequest doesn't handle well + // POST is included because CalDAV sharing requires specific Content-Type handling + const useDirectFetch = ['PROPPATCH', 'DELETE', 'POST'].includes(options.method); + + if (useDirectFetch) { + const response = await fetch(options.url, { + method: options.method, + headers: { + 'Content-Type': options.contentType ?? 'application/xml; charset=utf-8', + ...options.headers, + }, + body: options.body || undefined, + ...options.fetchOptions, + }); + + if (!response.ok && response.status !== 204 && response.status !== 207) { + const errorText = await response.text().catch(() => ''); + console.error(`[CalDAV] ${options.method} request failed:`, { + url: options.url, + status: response.status, + error: errorText, + }); + return { + success: false, + error: `Request failed: ${response.status} ${errorText}`, + status: response.status, + }; + } + + return { success: true }; + } + + // Use davRequest for standard WebDAV methods + const responses = await davRequest({ + url: options.url, + init: { + method: options.method as DAVMethods, + headers: { + 'Content-Type': options.contentType ?? 'application/xml; charset=utf-8', + ...options.headers, + }, + body: options.body, + }, + fetchOptions: options.fetchOptions, + }) + + const response = responses[0] + if (!response?.ok && response?.status !== 204) { + return { + success: false, + error: `Request failed: ${response?.status}`, + status: response?.status, + } + } + + return { success: true } + } catch (error) { + return { + success: false, + error: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** Standard PROPFIND props for calendar fetching */ +export const CALENDAR_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`]: {}, +} as const + +/** Execute PROPFIND with error handling */ +export async function executePropfind( + url: string, + props: Record, + options?: { + headers?: Record + fetchOptions?: RequestInit + depth?: '0' | '1' | 'infinity' + } +): Promise> { + try { + const response = await propfind({ + url, + props, + headers: options?.headers, + fetchOptions: options?.fetchOptions, + depth: options?.depth ?? '0', + }) + + const rs = response[0] + if (!rs.ok) { + return { + success: false, + error: `PROPFIND failed: ${rs.status}`, + status: rs.status, + } + } + + return { success: true, data: rs.props as T } + } catch (error) { + return { + success: false, + error: `PROPFIND failed: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ============================================================================ +// Response Parsing Helpers +// ============================================================================ + +/** Parse supported-calendar-component-set from PROPFIND response */ +export function parseCalendarComponents(supportedCalendarComponentSet: unknown): string[] | undefined { + if (!supportedCalendarComponentSet) return undefined + + const comp = (supportedCalendarComponentSet as Record).comp + if (Array.isArray(comp)) { + return comp + .map((sc: Record) => (sc._attributes as Record)?.name) + .filter(Boolean) + } + const name = (comp as Record)?._attributes as Record | undefined + return name?.name ? [name.name] : undefined +} + +/** Parse share status from invite response */ +export function parseShareStatus( + accepted: unknown, + noResponse: unknown +): 'pending' | 'accepted' | 'declined' { + if (accepted) return 'accepted' + if (noResponse) return 'pending' + return 'declined' +} + +/** Extract calendar URL from event URL */ +export function getCalendarUrlFromEventUrl(eventUrl: string): string { + const parts = eventUrl.split('/') + parts.pop() // Remove filename + return parts.join('/') + '/' +} + +/** Extract calendar ID from calendar URL (e.g., /calendars/user/calendar-id/ -> calendar-id) */ +export function getCalendarIdFromUrl(calendarUrl: string): string { + const parts = calendarUrl.replace(/\/$/, '').split('/') + return parts[parts.length - 1] +} + +// ============================================================================ +// Error Handling +// ============================================================================ + +/** Wrap async operation with standard error handling */ +export async function withErrorHandling( + operation: () => Promise, + errorPrefix: string +): Promise> { + try { + const data = await operation() + return { success: true, data } + } catch (error) { + return { + success: false, + error: `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}`, + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/constants.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/constants.ts new file mode 100644 index 0000000..8e5db47 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/constants.ts @@ -0,0 +1,16 @@ +export const attendeeRoleTypes = [ + 'CHAIR', + 'REQ-PARTICIPANT', + 'OPT-PARTICIPANT', + 'NON-PARTICIPANT', +] as const + +export const availableViews = [ + 'timeGridDay', + 'timeGridWeek', + 'dayGridMonth', + 'listDay', + 'listWeek', + 'listMonth', + 'listYear', +] as const