Skip to content
File

Blob: src/worker/carddav/vcard.ts

typescript103 lines
1export interface ParsedVCard {
2 body: string;
3 size: number;
4 uid: string;
5 version: "3.0" | "4.0";
6 fn: string | null;
7 nFamily: string | null;
8 nGiven: string | null;
9 org: string | null;
10 emails: string[];
11 tels: string[];
12}
13 
14function byteLength(value: string): number {
15 return new TextEncoder().encode(value).byteLength;
16}
17 
18function decodeUtf8(bytes: ArrayBuffer): string {
19 return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
20}
21 
22function normalizeLineEndings(text: string): string {
23 return `${text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "").split("\n").join("\r\n")}\r\n`;
24}
25 
26function unfoldLines(body: string): string[] {
27 const lines = body.replace(/\r\n/g, "\n").split("\n");
28 const unfolded: string[] = [];
29 for (const line of lines) {
30 if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) {
31 unfolded[unfolded.length - 1] += line.slice(1);
32 } else if (line !== "") {
33 unfolded.push(line);
34 }
35 }
36 return unfolded;
37}
38 
39function propertyName(line: string): string {
40 const end = line.search(/[:;]/);
41 const name = (end === -1 ? line : line.slice(0, end)).toUpperCase();
42 const groupIndex = name.lastIndexOf(".");
43 return groupIndex === -1 ? name : name.slice(groupIndex + 1);
44}
45 
46function propertyValue(line: string): string | null {
47 const colon = line.indexOf(":");
48 return colon === -1 ? null : line.slice(colon + 1);
49}
50 
51function firstValue(lines: string[], name: string): string | null {
52 const line = lines.find((candidate) => propertyName(candidate) === name);
53 return line ? propertyValue(line) : null;
54}
55 
56function firstPropertyIndex(lines: string[], name: string): number {
57 return lines.findIndex((candidate) => propertyName(candidate) === name);
58}
59 
60function allValues(lines: string[], name: string): string[] {
61 return lines
62 .filter((line) => propertyName(line) === name)
63 .map(propertyValue)
64 .filter((value): value is string => value !== null && value !== "");
65}
66 
67export function parseVCardBytes(bytes: ArrayBuffer, maxBytes: number): ParsedVCard {
68 if (bytes.byteLength > maxBytes) throw new Error("vCard exceeds configured maximum size");
69 const body = normalizeLineEndings(decodeUtf8(bytes));
70 const size = byteLength(body);
71 if (size > maxBytes) throw new Error("vCard exceeds configured maximum size after normalization");
72 const lines = unfoldLines(body);
73 if (lines[0]?.toUpperCase() !== "BEGIN:VCARD" || lines.at(-1)?.toUpperCase() !== "END:VCARD") {
74 throw new Error("vCard must contain one VCARD");
75 }
76 if (lines.slice(1, -1).some((line) => line.toUpperCase() === "BEGIN:VCARD" || line.toUpperCase() === "END:VCARD")) {
77 throw new Error("Multiple vCards are not supported");
78 }
79 
80 const versionIndex = firstPropertyIndex(lines, "VERSION");
81 const version = versionIndex === -1 ? null : propertyValue(lines[versionIndex]!);
82 if (version !== "3.0" && version !== "4.0") throw new Error("vCard VERSION must be 3.0 or 4.0");
83 if (version === "4.0" && versionIndex !== 1) throw new Error("vCard 4.0 VERSION must immediately follow BEGIN");
84 const uid = firstValue(lines, "UID");
85 if (!uid) throw new Error("vCard UID is required");
86 const fn = firstValue(lines, "FN");
87 if (version === "4.0" && !fn) throw new Error("vCard 4.0 FN is required");
88 
89 const nParts = (firstValue(lines, "N") ?? "").split(";");
90 return {
91 body,
92 size,
93 uid,
94 version,
95 fn,
96 nFamily: nParts[0] || null,
97 nGiven: nParts[1] || null,
98 org: firstValue(lines, "ORG"),
99 emails: allValues(lines, "EMAIL"),
100 tels: allValues(lines, "TEL"),
101 };
102}