Skip to content
File

Blob: src/worker/caldav/collections.ts

typescript74 lines
1import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser";
2import { CALDAV_NS, DAV_NS, qname, sameQName } from "@/worker/xml/namespaces";
3import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree";
4 
5export interface CalendarCollectionCreateRequest {
6 displayName: string | null;
7 timezoneIcal: string | null;
8}
9 
10export 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 
44function 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 
50function 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}