mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
1217 lines
42 KiB
TypeScript
1217 lines
42 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { parseVCard, generateVCard, detectDuplicates } from "../vcard";
|
|
import type { ContactCard } from "@/lib/jmap/types";
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("crypto", { randomUUID: () => "test-uuid" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("parseVCard", () => {
|
|
it("parses single vCard with FN (full name)", () => {
|
|
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
|
const result = parseVCard(vcf);
|
|
|
|
expect(result).toHaveLength(1);
|
|
const card = result[0];
|
|
expect(card.id).toBe("import-test-uuid");
|
|
expect(card.name?.components).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: "given", value: "John" },
|
|
{ kind: "surname", value: "Doe" },
|
|
])
|
|
);
|
|
expect(card.emails?.e0?.address).toBe("john@example.com");
|
|
});
|
|
|
|
it("parses vCard with N field (structured name with all components)", () => {
|
|
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;Michael;Mr.;Jr.\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
|
const result = parseVCard(vcf);
|
|
|
|
expect(result).toHaveLength(1);
|
|
const components = result[0].name?.components || [];
|
|
expect(components).toEqual([
|
|
{ kind: "title", value: "Mr." },
|
|
{ kind: "given", value: "John" },
|
|
{ kind: "given2", value: "Michael" },
|
|
{ kind: "surname", value: "Doe" },
|
|
{ kind: "generation", value: "Jr." },
|
|
]);
|
|
});
|
|
|
|
it("N field overrides FN when both present", () => {
|
|
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nN:Doe;John;;;\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
|
const result = parseVCard(vcf);
|
|
|
|
// N comes after FN in raw lines, and N always sets card.name (overwrites FN)
|
|
const components = result[0].name?.components || [];
|
|
expect(components.find((c) => c.kind === "given")?.value).toBe("John");
|
|
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
|
});
|
|
|
|
it("preserves FN as name.full so re-export keeps the mandatory FN (#430)", () => {
|
|
// FN alone
|
|
const fnOnly = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEND:VCARD`);
|
|
expect(fnOnly[0].name?.full).toBe("John Doe");
|
|
|
|
// FN before N: N overwrites components but must keep full
|
|
const fnFirst = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Dr. John Doe Jr.\r\nN:Doe;John;;;\r\nEND:VCARD`);
|
|
expect(fnFirst[0].name?.full).toBe("Dr. John Doe Jr.");
|
|
|
|
// N before FN: FN fills full on the existing name
|
|
const nFirst = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;;;\r\nFN:Dr. John Doe Jr.\r\nEND:VCARD`);
|
|
expect(nFirst[0].name?.full).toBe("Dr. John Doe Jr.");
|
|
expect(nFirst[0].name?.components?.find((c) => c.kind === "given")?.value).toBe("John");
|
|
});
|
|
|
|
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",
|
|
"VERSION:3.0",
|
|
"FN:Jane Smith",
|
|
"EMAIL;TYPE=WORK:jane@work.com",
|
|
"TEL;TYPE=CELL:+1234567890",
|
|
"ORG:Acme Corp;Engineering",
|
|
"ADR;TYPE=WORK:;;123 Main St;City;State;12345;US",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
const card = result[0];
|
|
|
|
expect(card.emails?.e0?.address).toBe("jane@work.com");
|
|
expect(card.emails?.e0?.contexts).toEqual({ work: true });
|
|
|
|
expect(card.phones?.p0?.number).toBe("+1234567890");
|
|
|
|
expect(card.organizations?.o0?.name).toBe("Acme Corp");
|
|
expect(card.organizations?.o0?.units).toEqual([{ name: "Engineering" }]);
|
|
|
|
expect(card.addresses?.a0).toMatchObject({
|
|
street: "123 Main St",
|
|
locality: "City",
|
|
region: "State",
|
|
postcode: "12345",
|
|
country: "US",
|
|
contexts: { work: true },
|
|
});
|
|
});
|
|
|
|
it("parses vCard with nickname, notes, and UID", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Bob Builder",
|
|
"NICKNAME:Bobby",
|
|
"NOTE:Important person",
|
|
"UID:abc-123",
|
|
"EMAIL:bob@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
const card = result[0];
|
|
|
|
expect(card.nicknames?.n0?.name).toBe("Bobby");
|
|
expect(card.notes?.n0?.note).toBe("Important person");
|
|
expect(card.uid).toBe("abc-123");
|
|
});
|
|
|
|
it("parses multi-contact vCard file", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Alice",
|
|
"EMAIL:alice@example.com",
|
|
"END:VCARD",
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Bob",
|
|
"EMAIL:bob@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].emails?.e0?.address).toBe("alice@example.com");
|
|
expect(result[1].emails?.e0?.address).toBe("bob@example.com");
|
|
});
|
|
|
|
it("skips malformed vCards without name or email", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"NOTE:Just a note",
|
|
"END:VCARD",
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Valid Contact",
|
|
"EMAIL:valid@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].emails?.e0?.address).toBe("valid@example.com");
|
|
});
|
|
|
|
it("parses vCard with group kind and members", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Team Alpha",
|
|
"KIND:group",
|
|
"MEMBER:urn:uuid:member-1",
|
|
"MEMBER:urn:uuid:member-2",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
const card = result[0];
|
|
|
|
expect(card.kind).toBe("group");
|
|
expect(card.members).toEqual({ "member-1": true, "member-2": true });
|
|
});
|
|
|
|
it("handles folded lines (continuation with leading space)", () => {
|
|
const vcf =
|
|
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John\r\n Doe\r\nEMAIL:john@example.com\r\nEND:VCARD";
|
|
const result = parseVCard(vcf);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name?.components).toEqual(
|
|
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
|
|
);
|
|
});
|
|
|
|
it("handles escaped characters", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Test User",
|
|
"NOTE:Line one\\nLine two\\, with comma\\; and semicolon\\\\backslash",
|
|
"EMAIL:test@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].notes?.n0?.note).toBe(
|
|
"Line one\nLine two, with comma; and semicolon\\backslash"
|
|
);
|
|
});
|
|
|
|
it("decodes the escaped colons Google Contacts writes (#1438)", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Test Example",
|
|
"NOTE:Reference\\: fictional value\\nSecond line\\, with comma\\; and semicolon",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].notes?.n0?.note).toBe(
|
|
"Reference: fictional value\nSecond line, with comma; and semicolon"
|
|
);
|
|
});
|
|
|
|
it("keeps an escaped backslash literal when an escapable character follows", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Paths",
|
|
"NOTE:C:\\\\new\\\\:dir\\\\\\:x\\\\\\\\",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].notes?.n0?.note).toBe("C:\\new\\:dir\\:x\\\\");
|
|
});
|
|
|
|
it("allows group kind without name or email", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"KIND:group",
|
|
"MEMBER:urn:uuid:m1",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
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",
|
|
"VERSION:4.0",
|
|
"FN:Jane Doe",
|
|
"GENDER:F;Female",
|
|
"LOGO;MEDIATYPE=image/png:https://example.com/logo.png",
|
|
"SOUND;MEDIATYPE=audio/ogg:https://example.com/sound.ogg",
|
|
"LABEL;TYPE=HOME:123 Main St\\nSpringfield, IL",
|
|
"ADR;TYPE=HOME:;;123 Main St;Springfield;IL;62704;US",
|
|
"CALURI:https://example.com/calendar/jane",
|
|
"CALADRURI:https://example.com/calendar/jane/schedule",
|
|
"FBURL:https://example.com/freebusy/jane",
|
|
"SOURCE:https://example.com/jane.vcf",
|
|
"EMAIL:jane@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
const card = result[0];
|
|
|
|
expect(card.speakToAs).toEqual({ grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } });
|
|
expect(card.media?.m0).toEqual({
|
|
kind: "logo",
|
|
uri: "https://example.com/logo.png",
|
|
mediaType: "image/png",
|
|
});
|
|
expect(card.media?.m1).toEqual({
|
|
kind: "sound",
|
|
uri: "https://example.com/sound.ogg",
|
|
mediaType: "audio/ogg",
|
|
});
|
|
expect(card.calendarUri).toBe("https://example.com/calendar/jane");
|
|
expect(card.schedulingUri).toBe("https://example.com/calendar/jane/schedule");
|
|
expect(card.freeBusyUri).toBe("https://example.com/freebusy/jane");
|
|
expect(card.source).toBe("https://example.com/jane.vcf");
|
|
// LABEL sets fullAddress on the ADR entry
|
|
expect(card.addresses?.a0?.fullAddress).toBe("123 Main St\nSpringfield, IL");
|
|
});
|
|
});
|
|
|
|
describe("generateVCard", () => {
|
|
it("exports single contact with all fields", () => {
|
|
const contact: ContactCard = {
|
|
id: "c1",
|
|
uid: "uid-1",
|
|
addressBookIds: { ab1: true },
|
|
kind: "individual",
|
|
name: {
|
|
components: [
|
|
{ kind: "prefix", value: "Dr." },
|
|
{ kind: "given", value: "Jane" },
|
|
{ kind: "additional", value: "Marie" },
|
|
{ kind: "surname", value: "Smith" },
|
|
{ kind: "suffix", value: "PhD" },
|
|
],
|
|
isOrdered: true,
|
|
},
|
|
emails: {
|
|
e0: { address: "jane@work.com", contexts: { work: true } },
|
|
e1: { address: "jane@home.com", contexts: { private: true } },
|
|
},
|
|
phones: { p0: { number: "+1234567890", contexts: { work: true } } },
|
|
organizations: {
|
|
o0: { name: "Acme Corp", units: [{ name: "Engineering" }] },
|
|
},
|
|
addresses: {
|
|
a0: {
|
|
street: "123 Main St",
|
|
locality: "City",
|
|
region: "State",
|
|
postcode: "12345",
|
|
country: "US",
|
|
contexts: { work: true },
|
|
},
|
|
},
|
|
nicknames: { n0: { name: "JJ" } },
|
|
notes: { n0: { note: "VIP client" } },
|
|
};
|
|
|
|
const vcf = generateVCard([contact]);
|
|
|
|
expect(vcf).toContain("BEGIN:VCARD");
|
|
expect(vcf).toContain("END:VCARD");
|
|
expect(vcf).toContain("VERSION:4.0");
|
|
expect(vcf).toContain("UID;VALUE=text:uid-1");
|
|
expect(vcf).toContain("KIND:individual");
|
|
expect(vcf).toContain("FN:Dr. Jane Marie Smith PhD");
|
|
expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD");
|
|
expect(vcf).toContain("NICKNAME:JJ");
|
|
expect(vcf).toContain("EMAIL;TYPE=WORK:jane@work.com");
|
|
expect(vcf).toContain("EMAIL;TYPE=HOME:jane@home.com");
|
|
expect(vcf).toContain("TEL;TYPE=WORK:+1234567890");
|
|
expect(vcf).toContain("ORG:Acme Corp;Engineering");
|
|
expect(vcf).toContain("ADR;TYPE=WORK:;;123 Main St;City;State;12345;US");
|
|
expect(vcf).toContain("NOTE:VIP client");
|
|
});
|
|
|
|
it("produces valid structure for minimal contact", () => {
|
|
const contact: ContactCard = {
|
|
id: "c2",
|
|
addressBookIds: {},
|
|
name: {
|
|
components: [{ kind: "given", value: "Solo" }],
|
|
isOrdered: true,
|
|
},
|
|
};
|
|
|
|
const vcf = generateVCard([contact]);
|
|
const lines = vcf.split("\r\n");
|
|
|
|
expect(lines[0]).toBe("BEGIN:VCARD");
|
|
expect(lines[1]).toBe("VERSION:4.0");
|
|
expect(lines).toContain("FN:Solo");
|
|
expect(lines).toContain("N:;Solo;;;");
|
|
// Every line ends with CRLF, END:VCARD included.
|
|
expect(vcf.endsWith("END:VCARD\r\n")).toBe(true);
|
|
});
|
|
|
|
it("exports GENDER, LOGO, SOUND, GEO, TZ, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
|
const contact: ContactCard = {
|
|
id: "c-new",
|
|
addressBookIds: {},
|
|
name: {
|
|
components: [{ kind: "given", value: "Jane" }],
|
|
isOrdered: true,
|
|
},
|
|
speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } },
|
|
media: {
|
|
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
|
|
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
|
|
},
|
|
addresses: {
|
|
a0: {
|
|
street: "123 Main St",
|
|
locality: "City",
|
|
coordinates: "geo:37.386013,-122.082932",
|
|
timeZone: "America/Los_Angeles",
|
|
},
|
|
},
|
|
calendarUri: "https://example.com/calendar/jane",
|
|
schedulingUri: "https://example.com/calendar/jane/schedule",
|
|
freeBusyUri: "https://example.com/freebusy/jane",
|
|
source: "https://example.com/jane.vcf",
|
|
};
|
|
|
|
const vcf = generateVCard([contact]);
|
|
expect(vcf).toContain("GENDER:F;Female");
|
|
expect(vcf).toContain("LOGO;VALUE=URI;MEDIATYPE=image/png:https://example.com/logo.png");
|
|
expect(vcf).toContain("SOUND;VALUE=URI;MEDIATYPE=audio/ogg:https://example.com/sound.ogg");
|
|
expect(vcf).toContain("GEO:geo:37.386013,-122.082932");
|
|
expect(vcf).toContain("TZ:America/Los_Angeles");
|
|
expect(vcf).toContain("CALURI:https://example.com/calendar/jane");
|
|
expect(vcf).toContain("CALADRURI:https://example.com/calendar/jane/schedule");
|
|
expect(vcf).toContain("FBURL:https://example.com/freebusy/jane");
|
|
expect(vcf).toContain("SOURCE:https://example.com/jane.vcf");
|
|
});
|
|
|
|
it("exports photos whose data: URI names no media type (Stalwart 0.16)", () => {
|
|
const contact: ContactCard = {
|
|
id: "c-photo",
|
|
addressBookIds: {},
|
|
name: { full: "Ann" },
|
|
media: {
|
|
// 0.16.23 (calcard 0.3.14) with the type in mediaType, 0.16.0 (calcard 0.3.2) with none.
|
|
a: { kind: "photo", uri: "data:;base64,/9j/", mediaType: "image/jpeg" },
|
|
b: { kind: "photo", uri: "data:base64,iVBO" },
|
|
c: { kind: "logo", uri: "data:image/png;base64,iVBO", mediaType: "image/png" },
|
|
},
|
|
};
|
|
|
|
const lines = generateVCard([contact]).split("\r\n");
|
|
expect(lines).toContain("PHOTO:data:image/jpeg;base64,/9j/");
|
|
expect(lines).toContain("PHOTO:data:;base64,iVBO");
|
|
expect(lines).toContain("LOGO:data:image/png;base64,iVBO");
|
|
|
|
const [reparsed] = parseVCard(generateVCard([contact]));
|
|
expect(Object.values(reparsed.media ?? {}).map(m => m.uri)).toEqual([
|
|
"data:image/jpeg;base64,/9j/",
|
|
"data:;base64,iVBO",
|
|
"data:image/png;base64,iVBO",
|
|
]);
|
|
});
|
|
|
|
it("writes nothing for a blob photo that was not downloaded first (Stalwart 1.0)", () => {
|
|
const contact: ContactCard = {
|
|
id: "c-blob",
|
|
addressBookIds: {},
|
|
name: { full: "Ann" },
|
|
media: { photo: { kind: "photo", blobId: "dbcX", mediaType: "image/jpeg" } },
|
|
};
|
|
expect(generateVCard([contact])).not.toContain("PHOTO");
|
|
});
|
|
|
|
it("encodes special characters in values", () => {
|
|
const contact: ContactCard = {
|
|
id: "c3",
|
|
addressBookIds: {},
|
|
name: {
|
|
components: [{ kind: "given", value: "Test" }],
|
|
isOrdered: true,
|
|
},
|
|
notes: { n0: { note: "Has comma, semicolon; and newline\nhere" } },
|
|
};
|
|
|
|
const vcf = generateVCard([contact]);
|
|
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
|
|
});
|
|
|
|
it("uses the organization name as FN for organization cards (issue #701)", () => {
|
|
const contact: ContactCard = {
|
|
id: "c4",
|
|
addressBookIds: {},
|
|
kind: "org",
|
|
name: { full: "Acme Corp" },
|
|
organizations: { o0: { name: "Acme Corp" } },
|
|
};
|
|
|
|
const vcf = generateVCard([contact]);
|
|
expect(vcf).toContain("KIND:org");
|
|
expect(vcf).toContain("FN:Acme Corp");
|
|
expect(vcf).toContain("ORG:Acme Corp");
|
|
});
|
|
|
|
it("falls back to ORG for FN when the card has no name at all", () => {
|
|
const contact: ContactCard = {
|
|
id: "c5",
|
|
addressBookIds: {},
|
|
kind: "org",
|
|
organizations: { o0: { name: "Acme Corp" } },
|
|
};
|
|
|
|
expect(generateVCard([contact])).toContain("FN:Acme Corp");
|
|
});
|
|
});
|
|
|
|
describe("vCard 4.0 export (issue #1407)", () => {
|
|
const card = (fields: Partial<ContactCard>): ContactCard => ({
|
|
id: "c1",
|
|
addressBookIds: {},
|
|
name: { full: "Jane Doe", components: [{ kind: "given", value: "Jane" }, { kind: "surname", value: "Doe" }] },
|
|
...fields,
|
|
});
|
|
const linesOf = (contact: ContactCard) => generateVCard([contact]).split("\r\n");
|
|
|
|
it("writes the card from the issue as valid vCard 4.0", () => {
|
|
const vcf = generateVCard([card({
|
|
uid: "sabre-vobject-5e64d0ac-69da-4393-bfe4-87844b9a7db6",
|
|
prodId: "-//MStilkerich//RCMCardDAV v4.4.6//EN",
|
|
updated: "2025-07-28T12:17:00Z",
|
|
emails: { e0: { address: "jane.doe@example.com", pref: 1 } },
|
|
phones: { p0: { number: "+33 6 00 00 00 00", features: { mobile: true, voice: true } } },
|
|
organizations: { o0: { name: "Example" } },
|
|
})]);
|
|
|
|
expect(vcf).toBe([
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"PRODID:-//JMAP-Webmail//EN",
|
|
"UID;VALUE=text:sabre-vobject-5e64d0ac-69da-4393-bfe4-87844b9a7db6",
|
|
"REV:20250728T121700Z",
|
|
"N:Doe;Jane;;;",
|
|
"FN:Jane Doe",
|
|
"EMAIL;PREF=1:jane.doe@example.com",
|
|
"TEL;TYPE=CELL,VOICE:+33 6 00 00 00 00",
|
|
"ORG:Example",
|
|
"END:VCARD",
|
|
"",
|
|
].join("\r\n"));
|
|
});
|
|
|
|
it("writes N before FN, which some importers need to read the name", () => {
|
|
const lines = linesOf(card({}));
|
|
expect(lines.indexOf("N:Doe;Jane;;;")).toBeLessThan(lines.indexOf("FN:Jane Doe"));
|
|
});
|
|
|
|
it("keeps a URI UID without VALUE=text", () => {
|
|
expect(linesOf(card({ uid: "urn:uuid:0f3c9d2e" }))).toContain("UID:urn:uuid:0f3c9d2e");
|
|
});
|
|
|
|
it.each([
|
|
[{ mobile: true }, "TEL;TYPE=CELL:+1"],
|
|
[{ cell: true }, "TEL;TYPE=CELL:+1"],
|
|
[{ mobile: true, cell: true }, "TEL;TYPE=CELL:+1"],
|
|
[{ "main-number": true }, "TEL;TYPE=MAIN-NUMBER:+1"],
|
|
[{ "x-sat": true, fax: true }, "TEL;TYPE=X-SAT,FAX:+1"],
|
|
[{ satellite: true }, "TEL:+1"],
|
|
])("writes phone features %o as %s", (features, expected) => {
|
|
expect(linesOf(card({ phones: { p0: { number: "+1", features } } }))).toContain(expected);
|
|
});
|
|
|
|
it.each(["CELL", "cell", "MOBILE"])("imports TEL;TYPE=%s as the RFC 9553 mobile feature", (type) => {
|
|
const [parsed] = parseVCard(`BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane\r\nTEL;TYPE=${type}:+1\r\nEND:VCARD\r\n`);
|
|
expect(parsed.phones?.p0?.features).toEqual({ mobile: true });
|
|
});
|
|
|
|
it("round-trips a mobile phone", () => {
|
|
const [reparsed] = parseVCard(generateVCard([card({ phones: { p0: { number: "+1", features: { mobile: true } } } })]));
|
|
expect(reparsed.phones?.p0?.features).toEqual({ mobile: true });
|
|
});
|
|
|
|
it("writes CREATED in the basic timestamp format", () => {
|
|
expect(linesOf(card({ created: "2024-01-01T08:30:15.250+02:00" }))).toContain("CREATED:20240101T083015+0200");
|
|
});
|
|
|
|
it("writes a bare RELATED name as text", () => {
|
|
const lines = linesOf(card({ relatedTo: {
|
|
"Sam Smith, Jr.": { relation: { sibling: true } },
|
|
"urn:uuid:42": { relation: { friend: true } },
|
|
} }));
|
|
expect(lines).toContain("RELATED;VALUE=text;TYPE=sibling:Sam Smith\\, Jr.");
|
|
expect(lines).toContain("RELATED;TYPE=friend:urn:uuid:42");
|
|
const [reparsed] = parseVCard(lines.join("\r\n"));
|
|
expect(Object.keys(reparsed.relatedTo ?? {})).toEqual(["Sam Smith, Jr.", "urn:uuid:42"]);
|
|
});
|
|
|
|
it("writes vCard 3.0 GEO coordinates as a geo: URI", () => {
|
|
expect(linesOf(card({ addresses: { a0: { coordinates: "37.386013;-122.082932" } } })))
|
|
.toContain("GEO:geo:37.386013,-122.082932");
|
|
});
|
|
|
|
it("folds long lines at 75 octets without splitting a UTF-8 character", () => {
|
|
const note = "Rendez-vous à l'hôtel de ville, salle des fêtes, près de l'église ".repeat(4).trim();
|
|
const vcf = generateVCard([card({ notes: { n0: { note } } })]);
|
|
const encoder = new TextEncoder();
|
|
for (const line of vcf.split("\r\n")) {
|
|
expect(encoder.encode(line).length).toBeLessThanOrEqual(75);
|
|
}
|
|
expect(vcf).toContain("\r\n ");
|
|
expect(parseVCard(vcf)[0].notes?.n0?.note).toBe(note);
|
|
});
|
|
|
|
it("folds an inline photo and reads it back", () => {
|
|
const data = "iVBORw0KGgo".repeat(40);
|
|
const vcf = generateVCard([card({ media: { m0: { kind: "photo", uri: `data:image/png;base64,${data}` } } })]);
|
|
expect(vcf.split("\r\n").every(line => line.length <= 75)).toBe(true);
|
|
expect(parseVCard(vcf)[0].media?.m0?.uri).toBe(`data:image/png;base64,${data}`);
|
|
});
|
|
|
|
it("ends every card with CRLF so cards concatenate cleanly", () => {
|
|
const vcf = generateVCard([card({ uid: "urn:uuid:a" }), card({ uid: "urn:uuid:b" })]);
|
|
expect(vcf).toContain("END:VCARD\r\nBEGIN:VCARD\r\n");
|
|
expect(vcf.endsWith("END:VCARD\r\n")).toBe(true);
|
|
expect(parseVCard(vcf).map(c => c.uid)).toEqual(["urn:uuid:a", "urn:uuid:b"]);
|
|
});
|
|
});
|
|
|
|
describe("organization-only cards (issue #701)", () => {
|
|
it("keeps a vCard that has only an organization name", () => {
|
|
const parsed = parseVCard([
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"KIND:org",
|
|
"ORG:Acme Corp",
|
|
"END:VCARD",
|
|
].join("\r\n"));
|
|
|
|
expect(parsed).toHaveLength(1);
|
|
expect(parsed[0].kind).toBe("org");
|
|
expect(parsed[0].organizations?.o0.name).toBe("Acme Corp");
|
|
});
|
|
});
|
|
|
|
describe("MEMBER export", () => {
|
|
const group = (members: Record<string, boolean>): ContactCard => ({
|
|
id: "g1",
|
|
addressBookIds: {},
|
|
kind: "group",
|
|
name: { full: "Team" },
|
|
members,
|
|
});
|
|
|
|
it("writes a member key that is already a URI as it is", () => {
|
|
// Groups made in Bulwark key members by uid (urn:uuid:); CardDAV groups
|
|
// keep whatever MEMBER value the client sent.
|
|
const vcf = generateVCard([group({ "urn:uuid:0f3c9d2e": true, "mailto:ann@example.com": true })]);
|
|
const lines = vcf.split("\r\n");
|
|
expect(lines).toContain("MEMBER:urn:uuid:0f3c9d2e");
|
|
expect(lines).toContain("MEMBER:mailto:ann@example.com");
|
|
expect(vcf).not.toContain("urn:uuid:urn:uuid:");
|
|
});
|
|
|
|
it("prefixes a bare key with urn:uuid:", () => {
|
|
expect(generateVCard([group({ "0f3c9d2e": true, gone: false })]).split("\r\n").filter(l => l.startsWith("MEMBER:")))
|
|
.toEqual(["MEMBER:urn:uuid:0f3c9d2e"]);
|
|
});
|
|
|
|
it("round-trips members through import and export", () => {
|
|
const [parsed] = parseVCard([
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:Team",
|
|
"KIND:group",
|
|
"MEMBER:urn:uuid:member-1",
|
|
"MEMBER:mailto:ann@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n"));
|
|
const [reparsed] = parseVCard(generateVCard([parsed]));
|
|
expect(reparsed.members).toEqual(parsed.members);
|
|
expect(generateVCard([parsed])).toContain("MEMBER:urn:uuid:member-1");
|
|
});
|
|
});
|
|
|
|
describe("round-trip: parse → generate → parse", () => {
|
|
it("produces structurally equivalent data", () => {
|
|
const original = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:John Doe",
|
|
"N:Doe;John;;;",
|
|
"EMAIL;TYPE=WORK:john@work.com",
|
|
"TEL:+1234567890",
|
|
"ORG:Acme Corp",
|
|
"NICKNAME:JD",
|
|
"NOTE:A note",
|
|
"UID:round-trip-1",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const parsed = parseVCard(original);
|
|
const exported = generateVCard(parsed);
|
|
const reparsed = parseVCard(exported);
|
|
|
|
expect(reparsed).toHaveLength(1);
|
|
const a = parsed[0];
|
|
const b = reparsed[0];
|
|
|
|
expect(b.name?.components).toEqual(a.name?.components);
|
|
expect(b.emails?.e0?.address).toBe(a.emails?.e0?.address);
|
|
expect(b.phones?.p0?.number).toBe(a.phones?.p0?.number);
|
|
expect(b.organizations?.o0?.name).toBe(a.organizations?.o0?.name);
|
|
expect(b.nicknames?.n0?.name).toBe(a.nicknames?.n0?.name);
|
|
expect(b.notes?.n0?.note).toBe(a.notes?.n0?.note);
|
|
expect(b.uid).toBe(a.uid);
|
|
});
|
|
});
|
|
|
|
describe("vCard 4.0 parsing (issue #289)", () => {
|
|
it("strips group prefix from property names (item1.EMAIL)", () => {
|
|
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
|
|
// can attach a label. We must still parse the EMAIL itself.
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Ada Lovelace",
|
|
"item1.EMAIL:ada@example.com",
|
|
"item1.X-ABLABEL:Personal",
|
|
"item2.TEL:tel:+1-555-0100",
|
|
"item2.X-ABLABEL:Mobile",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result).toHaveLength(1);
|
|
const card = result[0];
|
|
expect(card.emails?.e0?.address).toBe("ada@example.com");
|
|
expect(card.phones?.p0?.number).toBe("+1-555-0100");
|
|
});
|
|
|
|
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Alan Turing",
|
|
"EMAIL:mailto:alan@example.com",
|
|
"TEL;VALUE=uri:tel:+44-20-1234-5678",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
|
|
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
|
|
});
|
|
|
|
it("maps PREF=n parameter to pref field", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Grace Hopper",
|
|
"EMAIL;PREF=1:grace@home.example",
|
|
"EMAIL;PREF=2:grace@work.example",
|
|
"TEL;PREF=1:+1-555-9999",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].emails?.e0?.pref).toBe(1);
|
|
expect(result[0].emails?.e1?.pref).toBe(2);
|
|
expect(result[0].phones?.p0?.pref).toBe(1);
|
|
});
|
|
|
|
it("decodes RFC 6868 caret-encoded parameter values", () => {
|
|
// ^n → LF, ^^ → ^, ^' → DQUOTE
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Test",
|
|
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
|
|
"EMAIL:t@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
|
|
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
|
|
});
|
|
|
|
it("survives quoted parameter values containing semicolons", () => {
|
|
// Without quote-aware param splitting, the ; inside LABEL would shred
|
|
// the param list and the ADR would lose its TYPE.
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Lev",
|
|
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
|
|
"EMAIL:lev@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
|
|
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
|
|
expect(result[0].addresses?.a0?.locality).toBe("NYC");
|
|
});
|
|
|
|
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Marie Curie",
|
|
"BDAY:18671107",
|
|
"BIRTHPLACE:Warsaw\\, Poland",
|
|
"DEATHDATE:19340704",
|
|
"DEATHPLACE:Passy\\, France",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
const annivs = Object.values(result[0].anniversaries || {});
|
|
const birth = annivs.find((a) => a.kind === "birth");
|
|
const death = annivs.find((a) => a.kind === "death");
|
|
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
|
|
expect(death?.place?.fullAddress).toBe("Passy, France");
|
|
});
|
|
|
|
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Polymath",
|
|
"EXPERTISE;LEVEL=expert:cryptography",
|
|
"EXPERTISE;LEVEL=beginner:welding",
|
|
"HOBBY;LEVEL=high:gardening",
|
|
"INTEREST;LEVEL=medium:opera",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
const info = Object.values(result[0].personalInfo || {});
|
|
expect(info).toEqual(expect.arrayContaining([
|
|
{ kind: "expertise", value: "cryptography", level: "high" },
|
|
{ kind: "expertise", value: "welding", level: "low" },
|
|
{ kind: "hobby", value: "gardening", level: "high" },
|
|
{ kind: "interest", value: "opera", level: "medium" },
|
|
]));
|
|
});
|
|
|
|
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Corp Person",
|
|
"ORG-DIRECTORY:https://example.com/staff/",
|
|
"CONTACT-URI;PREF=1:https://example.com/contact",
|
|
"EMAIL:c@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
|
|
uri: "https://example.com/staff/",
|
|
kind: "directory",
|
|
});
|
|
const links = Object.values(result[0].links || {});
|
|
expect(links[0]).toMatchObject({
|
|
uri: "https://example.com/contact",
|
|
kind: "contact",
|
|
pref: 1,
|
|
});
|
|
});
|
|
|
|
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Modern Person",
|
|
"CREATED:20250101T120000Z",
|
|
"GRAMGENDER:neuter",
|
|
"PRONOUNS:they/them",
|
|
"PRONOUNS;PREF=2:ze/zir",
|
|
"EMAIL:m@example.com",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].created).toBe("20250101T120000Z");
|
|
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
|
|
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
|
|
expect(pronouns).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ pronouns: "they/them" }),
|
|
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
|
|
]));
|
|
});
|
|
|
|
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
|
|
for (const k of ["location", "device", "application"] as const) {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
`KIND:${k}`,
|
|
"FN:Thing",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
expect(parseVCard(vcf)[0].kind).toBe(k);
|
|
}
|
|
});
|
|
|
|
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
|
|
const vcf = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:GeoPerson",
|
|
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const result = parseVCard(vcf);
|
|
const addr = result[0].addresses?.a0;
|
|
expect(addr?.countryCode).toBe("DE");
|
|
expect(addr?.coordinates).toBe("52.5,13.4");
|
|
expect(addr?.timeZone).toBe("Europe/Berlin");
|
|
expect(addr?.fullAddress).toContain("Unter den Linden 1");
|
|
expect(addr?.locality).toBe("Berlin");
|
|
});
|
|
|
|
it("unfolds LF-only continuation lines (no CR)", () => {
|
|
// Unix exporters often use LF only; we must still unfold.
|
|
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
|
|
const result = parseVCard(vcf);
|
|
expect(result[0].name?.components).toEqual(
|
|
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
|
|
);
|
|
});
|
|
|
|
it("round-trips vCard 4.0-only properties through generateVCard", () => {
|
|
const original = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:4.0",
|
|
"FN:Round Trip",
|
|
"EMAIL;PREF=1:rt@example.com",
|
|
"BDAY:19700101",
|
|
"BIRTHPLACE:Somewhere",
|
|
"EXPERTISE;LEVEL=expert:vCard",
|
|
"HOBBY;LEVEL=medium:reading",
|
|
"ORG-DIRECTORY:https://example.com/dir",
|
|
"CONTACT-URI:https://example.com/contact",
|
|
"CREATED:20240101T000000Z",
|
|
"END:VCARD",
|
|
].join("\r\n");
|
|
|
|
const exported = generateVCard(parseVCard(original));
|
|
const reparsed = parseVCard(exported)[0];
|
|
|
|
expect(reparsed.emails?.e0?.pref).toBe(1);
|
|
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
|
|
const info = Object.values(reparsed.personalInfo || {});
|
|
expect(info).toEqual(expect.arrayContaining([
|
|
{ kind: "expertise", value: "vCard", level: "high" },
|
|
{ kind: "hobby", value: "reading", level: "medium" },
|
|
]));
|
|
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
|
|
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
|
|
uri: "https://example.com/contact",
|
|
kind: "contact",
|
|
});
|
|
expect(reparsed.created).toBe("20240101T000000Z");
|
|
});
|
|
});
|
|
|
|
describe("anniversary dates (issue #224)", () => {
|
|
const parseOne = (...props: string[]) =>
|
|
parseVCard(["BEGIN:VCARD", "VERSION:4.0", "FN:Test Person", ...props, "END:VCARD"].join("\r\n"))[0];
|
|
|
|
it("parses BDAY into a structured PartialDate, not a raw string", () => {
|
|
// JSContact rejects a string date, which is why imported birthdays used
|
|
// to disappear once they reached the server.
|
|
const card = parseOne("BDAY:19850412");
|
|
const birth = Object.values(card.anniversaries || {}).find(a => a.kind === "birth");
|
|
expect(birth?.date).toEqual({ year: 1985, month: 4, day: 12 });
|
|
});
|
|
|
|
it.each([
|
|
["BDAY:1985-04-12", { year: 1985, month: 4, day: 12 }],
|
|
["BDAY:19850412T232050Z", { year: 1985, month: 4, day: 12 }],
|
|
["BDAY:1985-04-12T00:00:00Z", { year: 1985, month: 4, day: 12 }],
|
|
["BDAY:--0412", { month: 4, day: 12 }],
|
|
["BDAY:--04-12", { month: 4, day: 12 }],
|
|
["BDAY:--04", { month: 4 }],
|
|
["BDAY:---12", { day: 12 }],
|
|
["BDAY:1985-04", { year: 1985, month: 4 }],
|
|
["BDAY:1985", { year: 1985 }],
|
|
])("parses %s", (prop, expected) => {
|
|
const card = parseOne(prop);
|
|
expect(Object.values(card.anniversaries || {})[0]?.date).toEqual(expected);
|
|
});
|
|
|
|
it.each([
|
|
"BDAY;VALUE=text:circa 1800",
|
|
"BDAY:not-a-date",
|
|
"BDAY:1985-13-01",
|
|
"BDAY:",
|
|
])("drops the unrepresentable date in %s", (prop) => {
|
|
expect(parseOne(prop).anniversaries).toBeUndefined();
|
|
});
|
|
|
|
it("keeps BDAY and ANNIVERSARY side by side instead of overwriting", () => {
|
|
const card = parseOne("ANNIVERSARY:20100601", "BDAY:19850412");
|
|
const kinds = Object.values(card.anniversaries || {}).map(a => a.kind);
|
|
expect(kinds).toEqual(expect.arrayContaining(["wedding", "birth"]));
|
|
expect(Object.keys(card.anniversaries || {})).toHaveLength(2);
|
|
});
|
|
|
|
it("round-trips a birthday through generateVCard", () => {
|
|
const exported = generateVCard([parseOne("BDAY:19850412")]);
|
|
expect(exported).toContain("BDAY:19850412");
|
|
const reparsed = parseVCard(exported)[0];
|
|
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
|
year: 1985, month: 4, day: 12,
|
|
});
|
|
});
|
|
|
|
it("round-trips a day/month-only birthday", () => {
|
|
const exported = generateVCard([parseOne("BDAY:--0412")]);
|
|
expect(exported).toContain("BDAY:--0412");
|
|
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
|
|
.toEqual({ month: 4, day: 12 });
|
|
});
|
|
});
|
|
|
|
describe("vendor extensions (issue #224)", () => {
|
|
const parseOne = (...props: string[]) =>
|
|
parseVCard(["BEGIN:VCARD", "VERSION:3.0", "FN:Test Person", ...props, "END:VCARD"].join("\r\n"))[0];
|
|
|
|
it("imports X-ANDROID-CUSTOM nicknames", () => {
|
|
const card = parseOne("X-ANDROID-CUSTOM:vnd.android.cursor.item/nickname;Bobby;1;;;;;;;;;;;;;");
|
|
expect(Object.values(card.nicknames || {})).toEqual([{ name: "Bobby" }]);
|
|
});
|
|
|
|
it("imports X-ANDROID-CUSTOM contact events as anniversaries", () => {
|
|
const card = parseOne(
|
|
"X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;1985-04-12;3;;;;;;;;;;;;;",
|
|
"X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;2010-06-01;1;;;;;;;;;;;;;",
|
|
);
|
|
expect(Object.values(card.anniversaries || {})).toEqual([
|
|
{ kind: "birth", date: { year: 1985, month: 4, day: 12 } },
|
|
{ kind: "wedding", date: { year: 2010, month: 6, day: 1 } },
|
|
]);
|
|
});
|
|
|
|
it("imports X-ANDROID-CUSTOM relations", () => {
|
|
const card = parseOne("X-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Jane Doe;14;;;;;;;;;;;;;");
|
|
expect(card.relatedTo?.["Jane Doe"]).toEqual({ relation: { spouse: true } });
|
|
});
|
|
|
|
it("imports Apple grouped X-ABDATE with its X-ABLABEL", () => {
|
|
const card = parseOne(
|
|
"item1.X-ABDATE:2010-06-01",
|
|
"item1.X-ABLABEL:_$!<Anniversary>!$_",
|
|
"item2.X-ABDATE:2015-09-20",
|
|
"item2.X-ABLABEL:First day at work",
|
|
);
|
|
expect(Object.values(card.anniversaries || {})).toEqual([
|
|
{ kind: "wedding", date: { year: 2010, month: 6, day: 1 } },
|
|
{ kind: "other", date: { year: 2015, month: 9, day: 20 } },
|
|
]);
|
|
});
|
|
|
|
it("applies a grouped X-ABLABEL to the property it labels", () => {
|
|
const card = parseOne(
|
|
"item1.TEL;TYPE=VOICE:+1-555-0100",
|
|
"item1.X-ABLABEL:Ski cabin",
|
|
"item2.EMAIL:side@example.com",
|
|
"item2.X-ABLABEL:_$!<Other>!$_",
|
|
);
|
|
expect(card.phones?.p0?.label).toBe("Ski cabin");
|
|
expect(card.emails?.e0?.label).toBe("Other");
|
|
});
|
|
|
|
it("imports X-ABRELATEDNAMES and X-SPOUSE as relations", () => {
|
|
const card = parseOne(
|
|
"item1.X-ABRELATEDNAMES:Sam Smith",
|
|
"item1.X-ABLABEL:_$!<Brother>!$_",
|
|
"X-SPOUSE:Alex Smith",
|
|
);
|
|
expect(card.relatedTo?.["Sam Smith"]).toEqual({ relation: { sibling: true } });
|
|
expect(card.relatedTo?.["Alex Smith"]).toEqual({ relation: { spouse: true } });
|
|
});
|
|
|
|
it("imports X- instant-messaging handles as online services", () => {
|
|
const card = parseOne("X-SKYPE-USERNAME:jdoe", "X-TWITTER:https://twitter.com/jdoe");
|
|
const services = Object.values(card.onlineServices || {});
|
|
expect(services).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ service: "Skype", uri: "jdoe", user: "jdoe" }),
|
|
expect.objectContaining({ service: "Twitter", uri: "https://twitter.com/jdoe" }),
|
|
]));
|
|
expect(services.find(s => s.service === "Twitter")?.user).toBeUndefined();
|
|
});
|
|
|
|
it("imports X-GENDER and round-trips X-MAIDENNAME", () => {
|
|
const card = parseOne("N:Smith;Jane;;;", "X-GENDER:Female", "X-MAIDENNAME:Brown");
|
|
expect(card.speakToAs?.grammaticalGender).toBe("feminine");
|
|
expect(card.name?.components).toEqual(
|
|
expect.arrayContaining([{ kind: "surname2", value: "Brown" }])
|
|
);
|
|
|
|
const reparsed = parseVCard(generateVCard([card]))[0];
|
|
expect(reparsed.name?.components).toEqual(
|
|
expect.arrayContaining([{ kind: "surname2", value: "Brown" }])
|
|
);
|
|
});
|
|
|
|
it("leaves unknown X- properties alone", () => {
|
|
const card = parseOne("X-SOMETHING-ELSE:whatever");
|
|
expect(card.onlineServices).toBeUndefined();
|
|
expect(card.notes).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("detectDuplicates", () => {
|
|
it("detects duplicates by matching email (case-insensitive)", () => {
|
|
const existing: ContactCard[] = [
|
|
{
|
|
id: "existing-1",
|
|
addressBookIds: {},
|
|
emails: { e0: { address: "Alice@Example.com" } },
|
|
},
|
|
];
|
|
const incoming: ContactCard[] = [
|
|
{
|
|
id: "new-1",
|
|
addressBookIds: {},
|
|
emails: { e0: { address: "alice@example.com" } },
|
|
},
|
|
];
|
|
|
|
const dupes = detectDuplicates(existing, incoming);
|
|
expect(dupes.size).toBe(1);
|
|
expect(dupes.get(0)).toBe("existing-1");
|
|
});
|
|
|
|
it("returns empty map when no duplicates", () => {
|
|
const existing: ContactCard[] = [
|
|
{
|
|
id: "existing-1",
|
|
addressBookIds: {},
|
|
emails: { e0: { address: "alice@example.com" } },
|
|
},
|
|
];
|
|
const incoming: ContactCard[] = [
|
|
{
|
|
id: "new-1",
|
|
addressBookIds: {},
|
|
emails: { e0: { address: "bob@example.com" } },
|
|
},
|
|
];
|
|
|
|
const dupes = detectDuplicates(existing, incoming);
|
|
expect(dupes.size).toBe(0);
|
|
});
|
|
|
|
it("handles contacts without emails", () => {
|
|
const existing: ContactCard[] = [
|
|
{ id: "existing-1", addressBookIds: {} },
|
|
];
|
|
const incoming: ContactCard[] = [
|
|
{ id: "new-1", addressBookIds: {} },
|
|
{
|
|
id: "new-2",
|
|
addressBookIds: {},
|
|
emails: { e0: { address: "a@b.com" } },
|
|
},
|
|
];
|
|
|
|
const dupes = detectDuplicates(existing, incoming);
|
|
expect(dupes.size).toBe(0);
|
|
});
|
|
});
|