feat: enhance contact management and vCard support

- Added support for parsing and generating additional vCard properties including GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, and SOURCE.
- Extended ContactCard interface to include new fields such as gender, media, anniversaries, online services, and personal info.
- Implemented logic to handle multi-part TLDs for domain extraction in avatars.
- Improved avatar component to prioritize contact photos and handle inline images in emails.
- Updated localization files to include new fields and labels for contact details.
- Refactored contact store to expose a method for retrieving contact photos.
- Enhanced unit tests to cover new vCard properties and ensure correct parsing and generation.
This commit is contained in:
Linus Rath
2026-03-12 15:54:09 +01:00
parent 4737f2928b
commit 7fedcb8e58
12 changed files with 2042 additions and 361 deletions
+81 -1
View File
@@ -203,6 +203,47 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
expect(result[0].kind).toBe("group");
});
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.gender).toEqual({ sex: "F", identity: "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", () => {
@@ -251,7 +292,7 @@ describe("generateVCard", () => {
expect(vcf).toContain("VERSION:3.0");
expect(vcf).toContain("UID:uid-1");
expect(vcf).toContain("KIND:individual");
expect(vcf).toContain("FN:Jane Smith");
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");
@@ -282,6 +323,45 @@ describe("generateVCard", () => {
expect(lines[lines.length - 1]).toBe("END:VCARD");
});
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,
},
gender: { sex: "F", identity: "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("encodes special characters in values", () => {
const contact: ContactCard = {
id: "c3",
+97 -3
View File
@@ -159,15 +159,33 @@ export interface ContactCard {
id: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org';
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
language?: string;
name?: ContactName;
nicknames?: Record<string, ContactNickname>;
emails?: Record<string, ContactEmail>;
phones?: Record<string, ContactPhone>;
onlineServices?: Record<string, ContactOnlineService>;
preferredLanguages?: Record<string, ContactLanguagePref>;
organizations?: Record<string, ContactOrganization>;
titles?: Record<string, ContactTitle>;
addresses?: Record<string, ContactAddress>;
nicknames?: Record<string, ContactNickname>;
anniversaries?: Record<string, ContactAnniversary>;
personalInfo?: Record<string, ContactPersonalInfo>;
notes?: Record<string, ContactNote>;
media?: Record<string, ContactMedia>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
links?: Record<string, ContactLink>;
relatedTo?: Record<string, ContactRelation>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
gender?: { sex?: string; identity?: string };
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -178,7 +196,7 @@ export interface ContactName {
}
export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional';
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
value: string;
}
@@ -186,17 +204,42 @@ export interface ContactEmail {
address: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactPhone {
number: string;
contexts?: Record<string, boolean>;
features?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactOnlineService {
service?: string;
uri: string;
user?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactLanguagePref {
language: string;
contexts?: Record<string, boolean>;
pref?: number;
}
export interface ContactOrganization {
name?: string;
units?: Array<{ name: string }>;
sortAs?: string;
}
export interface ContactTitle {
name: string;
kind?: 'title' | 'role';
organizationId?: string;
}
export interface ContactAddress {
@@ -205,16 +248,67 @@ export interface ContactAddress {
region?: string;
postcode?: string;
country?: string;
countryCode?: string;
fullAddress?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactNickname {
name: string;
contexts?: Record<string, boolean>;
}
export interface ContactNote {
note: string;
created?: string;
author?: { name?: string; uri?: string };
}
export interface ContactMedia {
kind: 'photo' | 'sound' | 'logo';
uri: string;
mediaType?: string;
}
export interface ContactAnniversary {
kind: 'birth' | 'death' | 'wedding' | 'other';
date: string;
place?: ContactAddress;
}
export interface ContactPersonalInfo {
kind: 'expertise' | 'hobby' | 'interest' | 'other';
value: string;
level?: 'high' | 'medium' | 'low';
}
export interface ContactCryptoKey {
uri: string;
mediaType?: string;
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry';
mediaType?: string;
}
export interface ContactLink {
uri: string;
kind?: 'contact' | 'generic';
mediaType?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactRelation {
relation?: Record<string, boolean>;
}
export interface AddressBook {
+400 -5
View File
@@ -1,4 +1,4 @@
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -30,7 +30,7 @@ function parseParams(paramStr: string): Record<string, string> {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF"].includes(upper)) {
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
@@ -38,6 +38,20 @@ function parseParams(paramStr: string): Record<string, string> {
return params;
}
const PHONE_FEATURE_TYPES = new Set(["CELL", "FAX", "VOICE", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"]);
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;
}
}
return Object.keys(features).length > 0 ? features : undefined;
}
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
@@ -150,6 +164,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.phones[`p${idx}`] = {
number: val,
contexts: typeToContext(params.TYPE),
features: typeToPhoneFeatures(params.TYPE),
};
break;
}
@@ -211,6 +226,248 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.members[memberUri] = true;
break;
}
case "PHOTO": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
// Inline base64 photo - construct a data URI
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/jpeg";
card.media[`m${idx}`] = {
kind: "photo",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
// URI value (data URI or URL)
card.media[`m${idx}`] = {
kind: "photo",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "TITLE": {
if (!card.titles) card.titles = {};
const idx = Object.keys(card.titles).length;
card.titles[`t${idx}`] = { name: val, kind: "title" };
break;
}
case "ROLE": {
if (!card.titles) card.titles = {};
const idx = Object.keys(card.titles).length;
card.titles[`t${idx}`] = { name: val, kind: "role" };
break;
}
case "URL": {
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
card.onlineServices[`u${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
label: params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE,
};
break;
}
case "IMPP":
case "X-SOCIALPROFILE": {
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
const svc: ContactOnlineService = {
uri: val,
contexts: typeToContext(params.TYPE),
};
if (params["X-SERVICE-TYPE"]) {
svc.service = params["X-SERVICE-TYPE"];
} else if (propName === "X-SOCIALPROFILE" && params.TYPE) {
const typeVal = params.TYPE.toLowerCase();
if (typeVal !== "work" && typeVal !== "home") {
svc.service = params.TYPE;
}
}
if (params["X-USER"]) svc.user = params["X-USER"];
card.onlineServices[`u${idx}`] = svc;
break;
}
case "BDAY": {
if (!card.anniversaries) card.anniversaries = {};
card.anniversaries.a0 = { kind: "birth", date: val };
break;
}
case "ANNIVERSARY":
case "X-ANNIVERSARY": {
if (!card.anniversaries) card.anniversaries = {};
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: "wedding", date: val };
break;
}
case "DEATHDATE":
case "X-DEATHDATE": {
if (!card.anniversaries) card.anniversaries = {};
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: "death", date: val };
break;
}
case "CATEGORIES": {
if (!card.keywords) card.keywords = {};
const cats = val.split(",").map(c => c.trim()).filter(Boolean);
for (const cat of cats) {
card.keywords[cat] = true;
}
break;
}
case "KEY": {
if (!card.cryptoKeys) card.cryptoKeys = {};
const idx = Object.keys(card.cryptoKeys).length;
card.cryptoKeys[`k${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "RELATED": {
if (!card.relatedTo) card.relatedTo = {};
const relType = params.TYPE?.toLowerCase();
const relation: Record<string, boolean> = {};
if (relType) relation[relType] = true;
card.relatedTo[val] = { relation: Object.keys(relation).length > 0 ? relation : undefined };
break;
}
case "LANG": {
if (!card.preferredLanguages) card.preferredLanguages = {};
const idx = Object.keys(card.preferredLanguages).length;
card.preferredLanguages[`l${idx}`] = {
language: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "PRODID":
card.prodId = val;
break;
case "REV":
card.updated = val;
break;
case "GEO": {
// Store GEO as coordinates on the first address, or create one
if (!card.addresses) card.addresses = {};
if (Object.keys(card.addresses).length === 0) {
card.addresses.a0 = { coordinates: val };
} else {
const firstKey = Object.keys(card.addresses)[0];
card.addresses[firstKey].coordinates = val;
}
break;
}
case "TZ": {
if (!card.addresses) card.addresses = {};
if (Object.keys(card.addresses).length === 0) {
card.addresses.a0 = { timeZone: val };
} else {
const firstKey = Object.keys(card.addresses)[0];
card.addresses[firstKey].timeZone = val;
}
break;
}
case "GENDER": {
const gParts = val.split(";");
card.gender = {};
if (gParts[0]) card.gender.sex = gParts[0];
if (gParts[1]) card.gender.identity = gParts[1];
break;
}
case "LOGO": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/png";
card.media[`m${idx}`] = {
kind: "logo",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
card.media[`m${idx}`] = {
kind: "logo",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "SOUND": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `audio/${mediaType.toLowerCase()}` : "audio/ogg";
card.media[`m${idx}`] = {
kind: "sound",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
card.media[`m${idx}`] = {
kind: "sound",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "LABEL": {
// Mailing label (v2.1/3.0) - store as fullAddress on last/new address
if (!card.addresses) card.addresses = {};
const addrKeys = Object.keys(card.addresses);
if (addrKeys.length > 0) {
const lastKey = addrKeys[addrKeys.length - 1];
card.addresses[lastKey].fullAddress = val;
} else {
card.addresses.a0 = { fullAddress: val, contexts: typeToContext(params.TYPE) };
}
break;
}
case "CALURI":
card.calendarUri = val;
break;
case "CALADRURI":
card.schedulingUri = val;
break;
case "FBURL":
card.freeBusyUri = val;
break;
case "SOURCE":
card.source = val;
break;
}
}
}
@@ -233,10 +490,18 @@ function generateSingleVCard(contact: ContactCard): string {
lines.push(`UID:${contact.uid}`);
}
if (contact.prodId) {
lines.push(`PRODID:${contact.prodId}`);
}
if (contact.kind) {
lines.push(`KIND:${contact.kind}`);
}
if (contact.updated) {
lines.push(`REV:${contact.updated}`);
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
@@ -244,7 +509,7 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [given, surname].filter(Boolean).join(" ");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ");
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
@@ -266,8 +531,15 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.phones) {
for (const phone of Object.values(contact.phones)) {
const type = contextToType(phone.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
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());
}
}
const typeParam = typeParts.length > 0 ? `;TYPE=${typeParts.join(",")}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
}
}
@@ -280,6 +552,16 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.titles) {
for (const title of Object.values(contact.titles)) {
if (title.kind === "role") {
lines.push(`ROLE:${encodeValue(title.name)}`);
} else {
lines.push(`TITLE:${encodeValue(title.name)}`);
}
}
}
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
@@ -297,6 +579,68 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.anniversaries) {
for (const ann of Object.values(contact.anniversaries)) {
if (ann.kind === "birth") {
lines.push(`BDAY:${ann.date}`);
} else if (ann.kind === "wedding") {
lines.push(`ANNIVERSARY:${ann.date}`);
} else if (ann.kind === "death") {
lines.push(`DEATHDATE:${ann.date}`);
}
}
}
if (contact.onlineServices) {
for (const svc of Object.values(contact.onlineServices)) {
if (svc.service || svc.user) {
// Output as IMPP for instant messaging / social profiles
const params: string[] = [];
if (svc.service) params.push(`X-SERVICE-TYPE=${svc.service}`);
const ctxType = contextToType(svc.contexts);
if (ctxType) params.push(`TYPE=${ctxType}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`IMPP${paramStr}:${svc.uri}`);
} else {
// Output as URL for plain web links
const type = contextToType(svc.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`URL${typeParam}:${svc.uri}`);
}
}
}
if (contact.keywords) {
const cats = Object.keys(contact.keywords).filter(k => contact.keywords![k]);
if (cats.length > 0) {
lines.push(`CATEGORIES:${cats.map(encodeValue).join(",")}`);
}
}
if (contact.preferredLanguages) {
for (const lang of Object.values(contact.preferredLanguages)) {
const type = contextToType(lang.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`LANG${typeParam}:${lang.language}`);
}
}
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}`);
}
}
if (contact.cryptoKeys) {
for (const key of Object.values(contact.cryptoKeys)) {
const type = contextToType(key.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`KEY${typeParam}:${key.uri}`);
}
}
if (contact.notes) {
for (const n of Object.values(contact.notes)) {
lines.push(`NOTE:${encodeValue(n.note)}`);
@@ -311,6 +655,57 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.media) {
for (const media of Object.values(contact.media)) {
if (media.uri) {
const prop = media.kind === "logo" ? "LOGO" : media.kind === "sound" ? "SOUND" : "PHOTO";
if (media.uri.startsWith("data:")) {
const match = media.uri.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
lines.push(`${prop};ENCODING=b;TYPE=${match[1]}:${match[2]}`);
}
} else {
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : "";
lines.push(`${prop};VALUE=URI${mt}:${media.uri}`);
}
}
}
}
// GEO and TZ from addresses
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
if (addr.coordinates) {
lines.push(`GEO:${addr.coordinates}`);
}
if (addr.timeZone) {
lines.push(`TZ:${addr.timeZone}`);
}
}
}
if (contact.gender) {
const sex = contact.gender.sex || "";
const identity = contact.gender.identity || "";
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
}
if (contact.calendarUri) {
lines.push(`CALURI:${contact.calendarUri}`);
}
if (contact.schedulingUri) {
lines.push(`CALADRURI:${contact.schedulingUri}`);
}
if (contact.freeBusyUri) {
lines.push(`FBURL:${contact.freeBusyUri}`);
}
if (contact.source) {
lines.push(`SOURCE:${contact.source}`);
}
lines.push("END:VCARD");
return lines.join("\r\n");
}