mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: export contacts as valid vCard 4.0 #1407
The export declared VERSION:3.0 but wrote vCard 4.0 syntax, and wrote the JSContact phone feature \"mobile\" as TYPE=MOBILE, which is no vCard type, so importers dropped such numbers. - Declare VERSION:4.0 and write 4.0 throughout: basic-format dates and timestamps, data: URIs for inline media, VALUE=text for a UID or RELATED that is no URI, geo: URIs, lines folded at 75 octets, CRLF after every line, and the webmail's own PRODID. - Map "mobile" to CELL and back; MAIN-NUMBER and x- types are kept. - Write N before FN: some importers ignore the name otherwise. - The contact form stores mobile phones as "mobile" (RFC 9553) and still reads the "cell" it used to write.
This commit is contained in:
@@ -54,8 +54,8 @@ describe('vCard export of contact photos', () => {
|
||||
const vcf = await exported!.text();
|
||||
expect(getBlobsAsBase64).toHaveBeenCalledWith(['dbcX', 'dbcLogo'], 'a');
|
||||
expect(fetchBlob).not.toHaveBeenCalled();
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/');
|
||||
expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:iVBO');
|
||||
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
|
||||
expect(vcf).toContain('LOGO:data:image/png;base64,iVBO');
|
||||
});
|
||||
|
||||
it('downloads Stalwart 1.0 blob photos one by one when the client cannot batch them', async () => {
|
||||
@@ -70,8 +70,8 @@ describe('vCard export of contact photos', () => {
|
||||
const vcf = await exported!.text();
|
||||
expect(fetchBlob).toHaveBeenCalledWith('dbcX', 'photo', 'image/jpeg', 'a');
|
||||
expect(fetchBlob).toHaveBeenCalledWith('dbcLogo', 'logo', 'image/png', 'a');
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/');
|
||||
expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:/9j/');
|
||||
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
|
||||
expect(vcf).toContain('LOGO:data:image/png;base64,/9j/');
|
||||
});
|
||||
|
||||
it('writes Stalwart 0.16 photos without a media type too', async () => {
|
||||
@@ -82,8 +82,8 @@ describe('vCard export of contact photos', () => {
|
||||
|
||||
const vcf = await exported!.text();
|
||||
expect(fetchBlob).not.toHaveBeenCalled();
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/');
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b:/9j/');
|
||||
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
|
||||
expect(vcf).toContain('PHOTO:data:;base64,/9j/');
|
||||
});
|
||||
|
||||
it('leaves out a photo that cannot be downloaded and exports the rest', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ interface EmailEntry {
|
||||
interface PhoneEntry {
|
||||
number: string;
|
||||
context: "work" | "private" | "";
|
||||
feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | "";
|
||||
feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | "";
|
||||
}
|
||||
|
||||
interface OnlineServiceEntry {
|
||||
@@ -319,7 +319,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
return Object.values(contact.phones).map(p => ({
|
||||
number: p.number,
|
||||
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
|
||||
feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "",
|
||||
// RFC 9553 calls it "mobile"; cards saved by older versions of this form say "cell".
|
||||
feature: p.features?.mobile || p.features?.cell ? "mobile" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "",
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
@@ -942,7 +943,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
>
|
||||
<option value="">{t("phone_type")}</option>
|
||||
<option value="voice">{t("phone_voice")}</option>
|
||||
<option value="cell">{t("phone_cell")}</option>
|
||||
<option value="mobile">{t("phone_cell")}</option>
|
||||
<option value="fax">{t("phone_fax")}</option>
|
||||
<option value="pager">{t("phone_pager")}</option>
|
||||
<option value="video">{t("phone_video")}</option>
|
||||
|
||||
+122
-10
@@ -373,8 +373,8 @@ describe("generateVCard", () => {
|
||||
|
||||
expect(vcf).toContain("BEGIN:VCARD");
|
||||
expect(vcf).toContain("END:VCARD");
|
||||
expect(vcf).toContain("VERSION:3.0");
|
||||
expect(vcf).toContain("UID:uid-1");
|
||||
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");
|
||||
@@ -401,10 +401,11 @@ describe("generateVCard", () => {
|
||||
const lines = vcf.split("\r\n");
|
||||
|
||||
expect(lines[0]).toBe("BEGIN:VCARD");
|
||||
expect(lines[1]).toBe("VERSION:3.0");
|
||||
expect(lines[1]).toBe("VERSION:4.0");
|
||||
expect(lines).toContain("FN:Solo");
|
||||
expect(lines).toContain("N:;Solo;;;");
|
||||
expect(lines[lines.length - 1]).toBe("END:VCARD");
|
||||
// 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", () => {
|
||||
@@ -460,14 +461,14 @@ describe("generateVCard", () => {
|
||||
};
|
||||
|
||||
const lines = generateVCard([contact]).split("\r\n");
|
||||
expect(lines).toContain("PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/");
|
||||
expect(lines).toContain("PHOTO;ENCODING=b:iVBO");
|
||||
expect(lines).toContain("LOGO;ENCODING=b;TYPE=image/png:iVBO");
|
||||
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:image/jpeg;base64,iVBO",
|
||||
"data:;base64,iVBO",
|
||||
"data:image/png;base64,iVBO",
|
||||
]);
|
||||
});
|
||||
@@ -524,6 +525,117 @@ describe("generateVCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
@@ -911,7 +1023,7 @@ describe("anniversary dates (issue #224)", () => {
|
||||
|
||||
it("round-trips a birthday through generateVCard", () => {
|
||||
const exported = generateVCard([parseOne("BDAY:19850412")]);
|
||||
expect(exported).toContain("BDAY:1985-04-12");
|
||||
expect(exported).toContain("BDAY:19850412");
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
||||
year: 1985, month: 4, day: 12,
|
||||
@@ -920,7 +1032,7 @@ describe("anniversary dates (issue #224)", () => {
|
||||
|
||||
it("round-trips a day/month-only birthday", () => {
|
||||
const exported = generateVCard([parseOne("BDAY:--0412")]);
|
||||
expect(exported).toContain("BDAY:--04-12");
|
||||
expect(exported).toContain("BDAY:--0412");
|
||||
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
|
||||
.toEqual({ month: 4, day: 12 });
|
||||
});
|
||||
|
||||
+127
-30
@@ -2,19 +2,21 @@ import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate,
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { parseBase64DataUri } from "@/lib/data-uri";
|
||||
|
||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to a vCard
|
||||
// 4.0 date, which uses the ISO 8601 basic format (RFC 6350 §4.3.1): 19850412,
|
||||
// --0412, 1985-04, 1985.
|
||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||
if (typeof date === 'string') return date;
|
||||
if (date && typeof date === 'object') {
|
||||
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||
return (date as { utc: string }).utc.split('T')[0];
|
||||
return (date as { utc: string }).utc.split('T')[0].replace(/-/g, '');
|
||||
}
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year && pd.month && pd.day) {
|
||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||
return `${String(pd.year).padStart(4, '0')}${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||
}
|
||||
if (pd.month && pd.day) {
|
||||
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||
return `--${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||
}
|
||||
if (pd.year && pd.month) {
|
||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||
@@ -323,20 +325,60 @@ function anniversaryKindFromLabel(label: string | undefined): "birth" | "wedding
|
||||
return "other";
|
||||
}
|
||||
|
||||
const PHONE_FEATURE_TYPES = new Set(["CELL", "FAX", "VOICE", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"]);
|
||||
// vCard TEL types (RFC 6350 §6.4.1, RFC 7852) and the RFC 9553 phone features
|
||||
// they stand for: vCard calls a mobile phone "cell", JSContact "mobile".
|
||||
// MOBILE is no vCard type, but Bulwark's own exports used to write it.
|
||||
const TEL_TYPE_TO_PHONE_FEATURE: Record<string, string> = {
|
||||
CELL: "mobile",
|
||||
MOBILE: "mobile",
|
||||
VOICE: "voice",
|
||||
FAX: "fax",
|
||||
PAGER: "pager",
|
||||
VIDEO: "video",
|
||||
TEXT: "text",
|
||||
TEXTPHONE: "textphone",
|
||||
"MAIN-NUMBER": "main-number",
|
||||
};
|
||||
|
||||
// The contact form stored mobile phones as "cell" before it used "mobile".
|
||||
const PHONE_FEATURE_TO_TEL_TYPE: Record<string, string> = {
|
||||
mobile: "CELL",
|
||||
cell: "CELL",
|
||||
voice: "VOICE",
|
||||
fax: "FAX",
|
||||
pager: "PAGER",
|
||||
video: "VIDEO",
|
||||
text: "TEXT",
|
||||
textphone: "TEXTPHONE",
|
||||
"main-number": "MAIN-NUMBER",
|
||||
};
|
||||
|
||||
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(",");
|
||||
const features: Record<string, boolean> = {};
|
||||
for (const t of types) {
|
||||
if (PHONE_FEATURE_TYPES.has(t)) {
|
||||
features[t.toLowerCase()] = true;
|
||||
}
|
||||
const feature = TEL_TYPE_TO_PHONE_FEATURE[t];
|
||||
if (feature) features[feature] = true;
|
||||
}
|
||||
return Object.keys(features).length > 0 ? features : undefined;
|
||||
}
|
||||
|
||||
function phoneFeaturesToTypes(features: Record<string, boolean> | undefined): string[] {
|
||||
const types = new Set<string>();
|
||||
for (const [feature, on] of Object.entries(features || {})) {
|
||||
if (!on) continue;
|
||||
const type = PHONE_FEATURE_TO_TEL_TYPE[feature.toLowerCase()];
|
||||
if (type) {
|
||||
types.add(type);
|
||||
} else if (/^x-[a-z0-9-]+$/i.test(feature)) {
|
||||
// RFC 6350 allows x-name TYPE values; anything else is not a TEL type.
|
||||
types.add(feature.toUpperCase());
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(",");
|
||||
@@ -1134,19 +1176,70 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
return card;
|
||||
}
|
||||
|
||||
// PRODID names the product that wrote the file, not the one that first made
|
||||
// the card, so the card's own prodId is not written back.
|
||||
const VCARD_PRODID = "-//JMAP-Webmail//EN";
|
||||
|
||||
function hasUriScheme(value: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(value);
|
||||
}
|
||||
|
||||
// JSContact timestamps are RFC 3339 (2025-07-28T12:17:00Z); vCard 4.0 wants
|
||||
// the ISO 8601 basic format (RFC 6350 §4.3.5): 20250728T121700Z.
|
||||
function toVcardTimestamp(value: string): string {
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i);
|
||||
if (!m) return value;
|
||||
const zone = (m[7] || "").replace(":", "").toUpperCase();
|
||||
return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}${m[6]}${zone}`;
|
||||
}
|
||||
|
||||
// vCard 4.0 GEO is a geo: URI; a vCard 3.0 import may have left "lat;lon".
|
||||
function toGeoUri(coordinates: string): string {
|
||||
if (/^geo:/i.test(coordinates)) return coordinates;
|
||||
const m = coordinates.match(/^\s*(-?[\d.]+)\s*[;,]\s*(-?[\d.]+)\s*$/);
|
||||
return m ? `geo:${m[1]},${m[2]}` : coordinates;
|
||||
}
|
||||
|
||||
function utf8Length(char: string): number {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code < 0x80) return 1;
|
||||
if (code < 0x800) return 2;
|
||||
if (code < 0x10000) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// RFC 6350 §3.2: lines SHOULD be folded at 75 octets, and a fold must not
|
||||
// split a multi-octet UTF-8 character. A continuation starts with one space.
|
||||
function foldLine(line: string): string {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let octets = 0;
|
||||
for (const char of line) {
|
||||
const size = utf8Length(char);
|
||||
const limit = out.length === 0 ? 75 : 74;
|
||||
if (octets + size > limit) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
octets = 0;
|
||||
}
|
||||
current += char;
|
||||
octets += size;
|
||||
}
|
||||
out.push(current);
|
||||
return out.join("\r\n ");
|
||||
}
|
||||
|
||||
/** Contacts as vCard 4.0 (RFC 6350), each line folded and ended by CRLF. */
|
||||
export function generateVCard(contacts: ContactCard[]): string {
|
||||
return contacts.map(generateSingleVCard).join("\r\n");
|
||||
return contacts.map(generateSingleVCard).join("");
|
||||
}
|
||||
|
||||
function generateSingleVCard(contact: ContactCard): string {
|
||||
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
|
||||
const lines: string[] = ["BEGIN:VCARD", "VERSION:4.0", `PRODID:${VCARD_PRODID}`];
|
||||
|
||||
if (contact.uid) {
|
||||
lines.push(`UID:${contact.uid}`);
|
||||
}
|
||||
|
||||
if (contact.prodId) {
|
||||
lines.push(`PRODID:${contact.prodId}`);
|
||||
// UID is a URI by default (RFC 6350 §6.7.6); other values must say so.
|
||||
lines.push(hasUriScheme(contact.uid) ? `UID:${contact.uid}` : `UID;VALUE=text:${contact.uid}`);
|
||||
}
|
||||
|
||||
if (contact.kind) {
|
||||
@@ -1154,7 +1247,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.updated) {
|
||||
lines.push(`REV:${contact.updated}`);
|
||||
lines.push(`REV:${toVcardTimestamp(contact.updated)}`);
|
||||
}
|
||||
|
||||
const components = contact.name?.components || [];
|
||||
@@ -1173,8 +1266,10 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
|| Object.values(contact.organizations || {})[0]?.name
|
||||
|| "";
|
||||
if (fn) {
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
// vCard does not order properties, but some importers drop the first
|
||||
// and last name unless N comes before FN (#1407).
|
||||
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
}
|
||||
|
||||
// vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension
|
||||
@@ -1206,11 +1301,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
const typeParts: string[] = [];
|
||||
const ctxType = contextToType(phone.contexts);
|
||||
if (ctxType) typeParts.push(ctxType);
|
||||
if (phone.features) {
|
||||
for (const feat of Object.keys(phone.features)) {
|
||||
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
|
||||
}
|
||||
}
|
||||
typeParts.push(...phoneFeaturesToTypes(phone.features));
|
||||
const params: string[] = [];
|
||||
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(",")}`);
|
||||
if (phone.pref) params.push(`PREF=${phone.pref}`);
|
||||
@@ -1341,8 +1432,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (contact.relatedTo) {
|
||||
for (const [uri, rel] of Object.entries(contact.relatedTo)) {
|
||||
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
||||
const typeParam = relType ? `;TYPE=${relType}` : "";
|
||||
lines.push(`RELATED${typeParam}:${uri}`);
|
||||
const params: string[] = [];
|
||||
// RELATED is a URI by default (RFC 6350 §6.6.6); a bare name, as
|
||||
// imported from Apple or Android, is text.
|
||||
if (!hasUriScheme(uri)) params.push("VALUE=text");
|
||||
if (relType) params.push(`TYPE=${relType}`);
|
||||
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
|
||||
lines.push(`RELATED${paramStr}:${hasUriScheme(uri) ? uri : encodeValue(uri)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1421,12 +1517,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (media?.uri) {
|
||||
const prop = media.kind === "logo" ? "LOGO" : media.kind === "sound" ? "SOUND" : "PHOTO";
|
||||
if (media.uri.startsWith("data:")) {
|
||||
// vCard 4.0 carries inline media as a data: URI (no ENCODING=b).
|
||||
// Stalwart writes photos without a media type as `data:;base64,`
|
||||
// (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in.
|
||||
const parsed = parseBase64DataUri(media.uri);
|
||||
if (parsed?.data) {
|
||||
const type = parsed.type || media.mediaType;
|
||||
lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ""}:${parsed.data}`);
|
||||
const type = parsed.type || media.mediaType || "";
|
||||
lines.push(`${prop}:data:${type};base64,${parsed.data}`);
|
||||
}
|
||||
} else {
|
||||
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : "";
|
||||
@@ -1440,7 +1537,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (contact.addresses) {
|
||||
for (const addr of Object.values(contact.addresses)) {
|
||||
if (addr.coordinates) {
|
||||
lines.push(`GEO:${addr.coordinates}`);
|
||||
lines.push(`GEO:${toGeoUri(addr.coordinates)}`);
|
||||
}
|
||||
if (addr.timeZone) {
|
||||
lines.push(`TZ:${addr.timeZone}`);
|
||||
@@ -1476,12 +1573,12 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.created) {
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
||||
lines.push(`CREATED:${contact.created}`);
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp.
|
||||
lines.push(`CREATED:${toVcardTimestamp(contact.created)}`);
|
||||
}
|
||||
|
||||
lines.push("END:VCARD");
|
||||
return lines.join("\r\n");
|
||||
return lines.map(line => `${foldLine(line)}\r\n`).join("");
|
||||
}
|
||||
|
||||
export function detectDuplicates(
|
||||
|
||||
Reference in New Issue
Block a user