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:
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user