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:
+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