From aadf56c27b43d990cae4edf2121ea49c445ac287 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:47:19 +0200 Subject: [PATCH] fix: emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import #224 #187 --- components/contacts/contact-form.tsx | 17 +++--- lib/__tests__/vcard.test.ts | 75 ++++++++++++++++++++++++- lib/vcard.ts | 83 ++++++++++++++++++++++++---- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 48348ca1..ae449b0b 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -147,7 +147,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc const t = useTranslations("contacts.form"); const isEditing = !!contact; - const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || ""; + // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. + const findComponent = (...kinds: string[]) => + contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; // Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input function anniversaryDateToString(date: AnniversaryDate): string { @@ -210,11 +212,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc }; } - const [prefix, setPrefix] = useState(findComponent("prefix")); + const [prefix, setPrefix] = useState(findComponent("title", "prefix")); const [givenName, setGivenName] = useState(findComponent("given")); - const [additionalName, setAdditionalName] = useState(findComponent("additional")); + const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); const [surname, setSurname] = useState(findComponent("surname")); - const [suffix, setSuffix] = useState(findComponent("suffix")); + const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [nickname, setNickname] = useState( contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : "" @@ -436,12 +438,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc phonesMap[`p${i}`] = obj; }); + // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly. const nameComponents = []; - if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() }); + if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); - if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() }); + if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); - if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() }); + if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); const titlesMap: Record = {}; if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index ccff3dc7..42fe627c 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -34,11 +34,11 @@ describe("parseVCard", () => { expect(result).toHaveLength(1); const components = result[0].name?.components || []; expect(components).toEqual([ - { kind: "prefix", value: "Mr." }, + { kind: "title", value: "Mr." }, { kind: "given", value: "John" }, - { kind: "additional", value: "Michael" }, + { kind: "given2", value: "Michael" }, { kind: "surname", value: "Doe" }, - { kind: "suffix", value: "Jr." }, + { kind: "generation", value: "Jr." }, ]); }); @@ -52,6 +52,21 @@ describe("parseVCard", () => { expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe"); }); + it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => { + // N: family;given;additional;prefix;suffix (RFC 6350 order) + const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`); + const c1 = withPrefix[0].name?.components || []; + expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith"); + expect(c1.find((c) => c.kind === "given")?.value).toBe("John"); + expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr."); + + const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`); + const c2 = withMiddle[0].name?.components || []; + expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith"); + expect(c2.find((c) => c.kind === "given")?.value).toBe("John"); + expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike"); + }); + it("parses vCard with phone, org, and address", () => { const vcf = [ "BEGIN:VCARD", @@ -204,6 +219,60 @@ describe("parseVCard", () => { expect(result[0].kind).toBe("group"); }); + it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => { + const vcf = [ + "BEGIN:VCARD", + "VERSION:2.1", + "N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;", + "FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller", + "NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe", + "EMAIL:hans@example.com", + "END:VCARD", + ].join("\r\n"); + + const result = parseVCard(vcf); + expect(result).toHaveLength(1); + const card = result[0]; + + const components = card.name?.components || []; + expect(components.find((c) => c.kind === "given")?.value).toBe("Hans"); + expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller"); + expect(card.notes?.n0?.note).toBe("Café straße"); + }); + + it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => { + const vcf = [ + "BEGIN:VCARD", + "VERSION:2.1", + "FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=", + "=C3=BCrgen=20M=C3=BCller", + "EMAIL:hj@example.com", + "END:VCARD", + ].join("\r\n"); + + const result = parseVCard(vcf); + expect(result).toHaveLength(1); + const components = result[0].name?.components || []; + const given = components.find((c) => c.kind === "given")?.value; + const surname = components.find((c) => c.kind === "surname")?.value; + expect(given).toBe("Hans"); + expect(surname).toBe("Jürgen Müller"); + }); + + it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => { + const vcf = [ + "BEGIN:VCARD", + "VERSION:2.1", + "FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9", + "EMAIL:c@example.com", + "END:VCARD", + ].join("\r\n"); + + const result = parseVCard(vcf); + const components = result[0].name?.components || []; + expect(components.find((c) => c.kind === "given")?.value).toBe("Café"); + }); + it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => { const vcf = [ "BEGIN:VCARD", diff --git a/lib/vcard.ts b/lib/vcard.ts index eafb32bf..3680986f 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string { return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } +// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues +// onto the next line. This is distinct from RFC 5545/6350 line folding (which +// uses leading whitespace and is already handled in unfoldLines). Only merge +// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't +// accidentally splice unrelated lines. +function joinQpSoftBreaks(lines: string[]): string[] { + const result: string[] = []; + let i = 0; + while (i < lines.length) { + let line = lines[i]; + if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) { + while (line.endsWith("=") && i + 1 < lines.length) { + i++; + line = line.slice(0, -1) + lines[i]; + } + } + result.push(line); + i++; + } + return result; +} + +function decodeQuotedPrintable(input: string, charset?: string): string { + const cleaned = input.replace(/=\r?\n/g, ""); + const bytes: number[] = []; + let i = 0; + while (i < cleaned.length) { + const ch = cleaned[i]; + if (ch === "=" && i + 2 < cleaned.length) { + const hex = cleaned.substring(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 3; + continue; + } + } + bytes.push(cleaned.charCodeAt(i) & 0xff); + i += 1; + } + const label = (charset || "utf-8").toLowerCase(); + try { + return new TextDecoder(label).decode(new Uint8Array(bytes)); + } catch { + return new TextDecoder("utf-8").decode(new Uint8Array(bytes)); + } +} + function decodeValue(raw: string): string { return raw .replace(/\\n/gi, "\n") @@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record { params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, ""); } else { const upper = part.toUpperCase(); - if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) { + if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") { + params.ENCODING = upper; + } else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) { params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper; } } @@ -118,7 +167,7 @@ function contextToType(contexts: Record | undefined): string { export function parseVCard(vcfString: string): ContactCard[] { const text = unfoldLines(vcfString); - const lines = text.split("\n"); + const lines = joinQpSoftBreaks(text.split("\n")); const contacts: ContactCard[] = []; let current: Record | null = null; @@ -163,8 +212,13 @@ function buildContact(raw: Record): ContactCard | null { const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : ""; const params = parseParams(paramStr); + const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE"; + for (const rawValue of values) { - const val = decodeValue(rawValue); + const decoded = isQuotedPrintable + ? decodeQuotedPrintable(rawValue, params.CHARSET) + : rawValue; + const val = decodeValue(decoded); switch (propName) { case "FN": @@ -182,13 +236,17 @@ function buildContact(raw: Record): ContactCard | null { break; case "N": { + // vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2) + // Mapped to JSContact-standard kinds (RFC 9553 §2.2.1): + // prefix→title, additional→given2, suffix→generation. + // Pushed in natural display order so `isOrdered: true` renders correctly. const nParts = val.split(";"); const components: NameComponent[] = []; - if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] }); + if (nParts[3]) components.push({ kind: "title", value: nParts[3] }); if (nParts[1]) components.push({ kind: "given", value: nParts[1] }); - if (nParts[2]) components.push({ kind: "additional", value: nParts[2] }); + if (nParts[2]) components.push({ kind: "given2", value: nParts[2] }); if (nParts[0]) components.push({ kind: "surname", value: nParts[0] }); - if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] }); + if (nParts[4]) components.push({ kind: "generation", value: nParts[4] }); if (components.length > 0) { card.name = { components, isOrdered: true }; } @@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string { } const components = contact.name?.components || []; - const given = components.find(c => c.kind === "given")?.value || ""; - const surname = components.find(c => c.kind === "surname")?.value || ""; - const prefix = components.find(c => c.kind === "prefix")?.value || ""; - const suffix = components.find(c => c.kind === "suffix")?.value || ""; - const additional = components.find(c => c.kind === "additional")?.value || ""; + const findKind = (...kinds: string[]) => + components.find(c => kinds.includes(c.kind))?.value || ""; + const given = findKind("given"); + const surname = findKind("surname"); + // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. + const prefix = findKind("title", "prefix"); + const suffix = findKind("generation", "suffix"); + const additional = findKind("given2", "additional", "middle"); const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || ""; if (fn) {