File
Blob: src/worker/caldav/collections.ts
| 1 | import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser"; |
| 2 | import { CALDAV_NS, DAV_NS, qname, sameQName } from "@/worker/xml/namespaces"; |
| 3 | import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree"; |
| 4 | |
| 5 | export interface CalendarCollectionCreateRequest { |
| 6 | displayName: string | null; |
| 7 | timezoneIcal: string | null; |
| 8 | } |
| 9 | |
| 10 | export function calendarCollectionCreateRequest( |
| 11 | xml: string, |
| 12 | maxBytes: number, |
| 13 | method: "MKCALENDAR" | "MKCOL", |
| 14 | ): CalendarCollectionCreateRequest { |
| 15 | // RFC 4791 5.3.1 lets MKCALENDAR accept an empty body. RFC 5689 MKCOL requires |
| 16 | // an extended body containing the desired resourcetype, so empty MKCOL must |
| 17 | // not be used as a calendar-creation shortcut. |
| 18 | if (xml.trim().length === 0) { |
| 19 | if (method === "MKCOL") { |
| 20 | throw new Error("MKCOL requires an RFC 5689 body with a calendar resourcetype"); |
| 21 | } |
| 22 | return { displayName: null, timezoneIcal: null }; |
| 23 | } |
| 24 | |
| 25 | const { root: rootElement } = parseSafeXml(xml, maxBytes); |
| 26 | const root = namedElement(rootElement); |
| 27 | if (method === "MKCALENDAR" && !sameQName(root.qname, qname(CALDAV_NS, "mkcalendar"))) { |
| 28 | throw new Error("Invalid MKCALENDAR body"); |
| 29 | } |
| 30 | if (method === "MKCOL" && !sameQName(root.qname, qname(DAV_NS, "mkcol"))) throw new Error("Invalid MKCOL body"); |
| 31 | if ( |
| 32 | method === "MKCOL" && |
| 33 | !elementsByQName(root.element, qname(DAV_NS, "resourcetype"), root.namespaces).some(hasCalendarResourceType) |
| 34 | ) { |
| 35 | throw new Error("MKCOL requires a calendar resourcetype"); |
| 36 | } |
| 37 | |
| 38 | return { |
| 39 | displayName: firstTextByQName(root.element, qname(DAV_NS, "displayname"), root.namespaces), |
| 40 | timezoneIcal: calendarTimezoneIcal(root.element, root.namespaces), |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | function hasCalendarResourceType(entry: { element: ParsedXmlElement; namespaces: Record<string, string> }): boolean { |
| 45 | return namedElementChildren(entry.element, entry.namespaces).some((child) => |
| 46 | sameQName(child.qname, qname(CALDAV_NS, "calendar")), |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | function calendarTimezoneIcal(element: ParsedXmlElement, namespaces: Record<string, string>): string | null { |
| 51 | const timezone = elementsByQName(element, qname(CALDAV_NS, "calendar-timezone"), namespaces).at(0); |
| 52 | if (!timezone) return null; |
| 53 | const value = textContent(timezone.element).trim(); |
| 54 | if (!value) throw new Error("CALDAV:calendar-timezone must not be empty"); |
| 55 | const lines = value |
| 56 | .replace(/\r\n/g, "\n") |
| 57 | .replace(/\r/g, "\n") |
| 58 | .split("\n") |
| 59 | .map((line) => line.trim()); |
| 60 | if (!lines.some((line) => line.toUpperCase() === "BEGIN:VTIMEZONE")) { |
| 61 | throw new Error("CALDAV:calendar-timezone must contain a VTIMEZONE component"); |
| 62 | } |
| 63 | const tzid = lines |
| 64 | .map((line) => /^TZID(?:;[^:]*)?:(.+)$/i.exec(line)?.[1]?.trim()) |
| 65 | .find((entry): entry is string => Boolean(entry)); |
| 66 | if (!tzid) throw new Error("CALDAV:calendar-timezone VTIMEZONE must include TZID"); |
| 67 | try { |
| 68 | new Intl.DateTimeFormat("en-US", { timeZone: tzid }).format(new Date(0)); |
| 69 | } catch { |
| 70 | throw new Error(`Unsupported CALDAV:calendar-timezone TZID ${tzid}`); |
| 71 | } |
| 72 | return value; |
| 73 | } |