import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser"; import { CALDAV_NS, DAV_NS, qname, sameQName } from "@/worker/xml/namespaces"; import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree"; export interface CalendarCollectionCreateRequest { displayName: string | null; timezoneIcal: string | null; } export function calendarCollectionCreateRequest( xml: string, maxBytes: number, method: "MKCALENDAR" | "MKCOL", ): CalendarCollectionCreateRequest { // RFC 4791 5.3.1 lets MKCALENDAR accept an empty body. RFC 5689 MKCOL requires // an extended body containing the desired resourcetype, so empty MKCOL must // not be used as a calendar-creation shortcut. if (xml.trim().length === 0) { if (method === "MKCOL") { throw new Error("MKCOL requires an RFC 5689 body with a calendar resourcetype"); } return { displayName: null, timezoneIcal: null }; } const { root: rootElement } = parseSafeXml(xml, maxBytes); const root = namedElement(rootElement); if (method === "MKCALENDAR" && !sameQName(root.qname, qname(CALDAV_NS, "mkcalendar"))) { throw new Error("Invalid MKCALENDAR body"); } if (method === "MKCOL" && !sameQName(root.qname, qname(DAV_NS, "mkcol"))) throw new Error("Invalid MKCOL body"); if ( method === "MKCOL" && !elementsByQName(root.element, qname(DAV_NS, "resourcetype"), root.namespaces).some(hasCalendarResourceType) ) { throw new Error("MKCOL requires a calendar resourcetype"); } return { displayName: firstTextByQName(root.element, qname(DAV_NS, "displayname"), root.namespaces), timezoneIcal: calendarTimezoneIcal(root.element, root.namespaces), }; } function hasCalendarResourceType(entry: { element: ParsedXmlElement; namespaces: Record }): boolean { return namedElementChildren(entry.element, entry.namespaces).some((child) => sameQName(child.qname, qname(CALDAV_NS, "calendar")), ); } function calendarTimezoneIcal(element: ParsedXmlElement, namespaces: Record): string | null { const timezone = elementsByQName(element, qname(CALDAV_NS, "calendar-timezone"), namespaces).at(0); if (!timezone) return null; const value = textContent(timezone.element).trim(); if (!value) throw new Error("CALDAV:calendar-timezone must not be empty"); const lines = value .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .split("\n") .map((line) => line.trim()); if (!lines.some((line) => line.toUpperCase() === "BEGIN:VTIMEZONE")) { throw new Error("CALDAV:calendar-timezone must contain a VTIMEZONE component"); } const tzid = lines .map((line) => /^TZID(?:;[^:]*)?:(.+)$/i.exec(line)?.[1]?.trim()) .find((entry): entry is string => Boolean(entry)); if (!tzid) throw new Error("CALDAV:calendar-timezone VTIMEZONE must include TZID"); try { new Intl.DateTimeFormat("en-US", { timeZone: tzid }).format(new Date(0)); } catch { throw new Error(`Unsupported CALDAV:calendar-timezone TZID ${tzid}`); } return value; }