export interface ParsedVCard { body: string; size: number; uid: string; version: "3.0" | "4.0"; fn: string | null; nFamily: string | null; nGiven: string | null; org: string | null; emails: string[]; tels: string[]; } function byteLength(value: string): number { return new TextEncoder().encode(value).byteLength; } function decodeUtf8(bytes: ArrayBuffer): string { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); } function normalizeLineEndings(text: string): string { return `${text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "").split("\n").join("\r\n")}\r\n`; } function unfoldLines(body: string): string[] { const lines = body.replace(/\r\n/g, "\n").split("\n"); const unfolded: string[] = []; for (const line of lines) { if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) { unfolded[unfolded.length - 1] += line.slice(1); } else if (line !== "") { unfolded.push(line); } } return unfolded; } function propertyName(line: string): string { const end = line.search(/[:;]/); const name = (end === -1 ? line : line.slice(0, end)).toUpperCase(); const groupIndex = name.lastIndexOf("."); return groupIndex === -1 ? name : name.slice(groupIndex + 1); } function propertyValue(line: string): string | null { const colon = line.indexOf(":"); return colon === -1 ? null : line.slice(colon + 1); } function firstValue(lines: string[], name: string): string | null { const line = lines.find((candidate) => propertyName(candidate) === name); return line ? propertyValue(line) : null; } function firstPropertyIndex(lines: string[], name: string): number { return lines.findIndex((candidate) => propertyName(candidate) === name); } function allValues(lines: string[], name: string): string[] { return lines .filter((line) => propertyName(line) === name) .map(propertyValue) .filter((value): value is string => value !== null && value !== ""); } export function parseVCardBytes(bytes: ArrayBuffer, maxBytes: number): ParsedVCard { if (bytes.byteLength > maxBytes) throw new Error("vCard exceeds configured maximum size"); const body = normalizeLineEndings(decodeUtf8(bytes)); const size = byteLength(body); if (size > maxBytes) throw new Error("vCard exceeds configured maximum size after normalization"); const lines = unfoldLines(body); if (lines[0]?.toUpperCase() !== "BEGIN:VCARD" || lines.at(-1)?.toUpperCase() !== "END:VCARD") { throw new Error("vCard must contain one VCARD"); } if (lines.slice(1, -1).some((line) => line.toUpperCase() === "BEGIN:VCARD" || line.toUpperCase() === "END:VCARD")) { throw new Error("Multiple vCards are not supported"); } const versionIndex = firstPropertyIndex(lines, "VERSION"); const version = versionIndex === -1 ? null : propertyValue(lines[versionIndex]!); if (version !== "3.0" && version !== "4.0") throw new Error("vCard VERSION must be 3.0 or 4.0"); if (version === "4.0" && versionIndex !== 1) throw new Error("vCard 4.0 VERSION must immediately follow BEGIN"); const uid = firstValue(lines, "UID"); if (!uid) throw new Error("vCard UID is required"); const fn = firstValue(lines, "FN"); if (version === "4.0" && !fn) throw new Error("vCard 4.0 FN is required"); const nParts = (firstValue(lines, "N") ?? "").split(";"); return { body, size, uid, version, fn, nFamily: nParts[0] || null, nGiven: nParts[1] || null, org: firstValue(lines, "ORG"), emails: allValues(lines, "EMAIL"), tels: allValues(lines, "TEL"), }; }