This commit is contained in:
@@ -147,7 +147,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
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
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
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 [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 [surname, setSurname] = useState(findComponent("surname"));
|
||||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(
|
const [nickname, setNickname] = useState(
|
||||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||||
@@ -436,12 +438,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
phonesMap[`p${i}`] = obj;
|
phonesMap[`p${i}`] = obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
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 (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 (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<string, { name: string; kind?: "title" | "role" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const components = result[0].name?.components || [];
|
const components = result[0].name?.components || [];
|
||||||
expect(components).toEqual([
|
expect(components).toEqual([
|
||||||
{ kind: "prefix", value: "Mr." },
|
{ kind: "title", value: "Mr." },
|
||||||
{ kind: "given", value: "John" },
|
{ kind: "given", value: "John" },
|
||||||
{ kind: "additional", value: "Michael" },
|
{ kind: "given2", value: "Michael" },
|
||||||
{ kind: "surname", value: "Doe" },
|
{ 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");
|
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", () => {
|
it("parses vCard with phone, org, and address", () => {
|
||||||
const vcf = [
|
const vcf = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
|
|||||||
expect(result[0].kind).toBe("group");
|
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", () => {
|
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
||||||
const vcf = [
|
const vcf = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
|
|||||||
+72
-11
@@ -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");
|
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 {
|
function decodeValue(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/\\n/gi, "\n")
|
.replace(/\\n/gi, "\n")
|
||||||
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record<string, string> {
|
|||||||
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
|
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
|
||||||
} else {
|
} else {
|
||||||
const upper = part.toUpperCase();
|
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;
|
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +167,7 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
|
|||||||
|
|
||||||
export function parseVCard(vcfString: string): ContactCard[] {
|
export function parseVCard(vcfString: string): ContactCard[] {
|
||||||
const text = unfoldLines(vcfString);
|
const text = unfoldLines(vcfString);
|
||||||
const lines = text.split("\n");
|
const lines = joinQpSoftBreaks(text.split("\n"));
|
||||||
const contacts: ContactCard[] = [];
|
const contacts: ContactCard[] = [];
|
||||||
let current: Record<string, string[]> | null = null;
|
let current: Record<string, string[]> | null = null;
|
||||||
|
|
||||||
@@ -163,8 +212,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
|
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
|
||||||
const params = parseParams(paramStr);
|
const params = parseParams(paramStr);
|
||||||
|
|
||||||
|
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
|
||||||
|
|
||||||
for (const rawValue of values) {
|
for (const rawValue of values) {
|
||||||
const val = decodeValue(rawValue);
|
const decoded = isQuotedPrintable
|
||||||
|
? decodeQuotedPrintable(rawValue, params.CHARSET)
|
||||||
|
: rawValue;
|
||||||
|
const val = decodeValue(decoded);
|
||||||
|
|
||||||
switch (propName) {
|
switch (propName) {
|
||||||
case "FN":
|
case "FN":
|
||||||
@@ -182,13 +236,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "N": {
|
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 nParts = val.split(";");
|
||||||
const components: NameComponent[] = [];
|
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[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[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) {
|
if (components.length > 0) {
|
||||||
card.name = { components, isOrdered: true };
|
card.name = { components, isOrdered: true };
|
||||||
}
|
}
|
||||||
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const components = contact.name?.components || [];
|
const components = contact.name?.components || [];
|
||||||
const given = components.find(c => c.kind === "given")?.value || "";
|
const findKind = (...kinds: string[]) =>
|
||||||
const surname = components.find(c => c.kind === "surname")?.value || "";
|
components.find(c => kinds.includes(c.kind))?.value || "";
|
||||||
const prefix = components.find(c => c.kind === "prefix")?.value || "";
|
const given = findKind("given");
|
||||||
const suffix = components.find(c => c.kind === "suffix")?.value || "";
|
const surname = findKind("surname");
|
||||||
const additional = components.find(c => c.kind === "additional")?.value || "";
|
// 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 || "";
|
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
|
||||||
if (fn) {
|
if (fn) {
|
||||||
|
|||||||
Reference in New Issue
Block a user