import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { generateUUID } from "@/lib/utils"; // Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string 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]; } 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')}`; } if (pd.month && pd.day) { 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')}`; } if (pd.year) return String(pd.year); } return String(date); } const VCARD_SEX_TO_GENDER: Record = { M: "masculine", F: "feminine", O: "other", N: "none", U: "unknown", }; const GENDER_TO_VCARD_SEX: Record = { masculine: "M", feminine: "F", other: "O", none: "N", unknown: "U", }; function vcardSexToGrammaticalGender(sex: string): string { return VCARD_SEX_TO_GENDER[sex.toUpperCase()] || sex.toLowerCase(); } function grammaticalGenderToVcardSex(gender: string): string { return GENDER_TO_VCARD_SEX[gender.toLowerCase()] || ""; } function unfoldLines(vcf: string): string { return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } function decodeValue(raw: string): string { return raw .replace(/\\n/gi, "\n") .replace(/\\,/g, ",") .replace(/\\;/g, ";") .replace(/\\\\/g, "\\"); } function encodeValue(val: string): string { return val .replace(/\\/g, "\\\\") .replace(/;/g, "\\;") .replace(/,/g, "\\,") .replace(/\n/g, "\\n"); } function parseParams(paramStr: string): Record { const params: Record = {}; if (!paramStr) return params; const parts = paramStr.split(";"); for (const part of parts) { const eq = part.indexOf("="); if (eq > 0) { params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, ""); } else { const upper = part.toUpperCase(); if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) { params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper; } } } return params; } const PHONE_FEATURE_TYPES = new Set(["CELL", "FAX", "VOICE", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"]); function typeToPhoneFeatures(typeStr: string | undefined): Record | undefined { if (!typeStr) return undefined; const types = typeStr.toUpperCase().split(","); const features: Record = {}; 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 | undefined { if (!typeStr) return undefined; const types = typeStr.toUpperCase().split(","); const ctx: Record = {}; if (types.includes("WORK")) ctx.work = true; if (types.includes("HOME")) ctx.private = true; if (!ctx.work && !ctx.private) return undefined; return ctx; } function contextToType(contexts: Record | undefined): string { if (!contexts) return ""; if (contexts.work) return "WORK"; if (contexts.private) return "HOME"; return ""; } export function parseVCard(vcfString: string): ContactCard[] { const text = unfoldLines(vcfString); const lines = text.split("\n"); const contacts: ContactCard[] = []; let current: Record | null = null; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.toUpperCase() === "BEGIN:VCARD") { current = {}; continue; } if (trimmed.toUpperCase() === "END:VCARD") { if (current) { const card = buildContact(current); if (card) contacts.push(card); } current = null; continue; } if (current) { const colonIdx = trimmed.indexOf(":"); if (colonIdx < 1) continue; const keyPart = trimmed.substring(0, colonIdx); const value = trimmed.substring(colonIdx + 1); if (!current[keyPart]) current[keyPart] = []; current[keyPart].push(value); } } return contacts; } function buildContact(raw: Record): ContactCard | null { const id = `import-${generateUUID()}`; const card: ContactCard = { id, addressBookIds: {} }; for (const [fullKey, values] of Object.entries(raw)) { const semiIdx = fullKey.indexOf(";"); const propName = (semiIdx > 0 ? fullKey.substring(0, semiIdx) : fullKey).toUpperCase(); const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : ""; const params = parseParams(paramStr); for (const rawValue of values) { const val = decodeValue(rawValue); switch (propName) { case "FN": if (!card.name) { const parts = val.split(" "); const components: NameComponent[] = []; if (parts.length >= 2) { components.push({ kind: "given", value: parts[0] }); components.push({ kind: "surname", value: parts.slice(1).join(" ") }); } else if (parts.length === 1) { components.push({ kind: "given", value: parts[0] }); } card.name = { components, isOrdered: true }; } break; case "N": { const nParts = val.split(";"); const components: NameComponent[] = []; if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] }); if (nParts[1]) components.push({ kind: "given", value: nParts[1] }); if (nParts[2]) components.push({ kind: "additional", value: nParts[2] }); if (nParts[0]) components.push({ kind: "surname", value: nParts[0] }); if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] }); if (components.length > 0) { card.name = { components, isOrdered: true }; } break; } case "EMAIL": { if (!card.emails) card.emails = {}; const idx = Object.keys(card.emails).length; card.emails[`e${idx}`] = { address: val, contexts: typeToContext(params.TYPE), }; break; } case "TEL": { if (!card.phones) card.phones = {}; const idx = Object.keys(card.phones).length; card.phones[`p${idx}`] = { number: val, contexts: typeToContext(params.TYPE), features: typeToPhoneFeatures(params.TYPE), }; break; } case "ORG": { if (!card.organizations) card.organizations = {}; const orgParts = val.split(";").filter(Boolean); const idx = Object.keys(card.organizations).length; card.organizations[`o${idx}`] = { name: orgParts[0], units: orgParts.slice(1).map(u => ({ name: u })), }; break; } case "ADR": { if (!card.addresses) card.addresses = {}; const adrParts = val.split(";"); const idx = Object.keys(card.addresses).length; card.addresses[`a${idx}`] = { street: adrParts[2] || undefined, locality: adrParts[3] || undefined, region: adrParts[4] || undefined, postcode: adrParts[5] || undefined, country: adrParts[6] || undefined, contexts: typeToContext(params.TYPE), }; break; } case "NOTE": { if (!card.notes) card.notes = {}; const idx = Object.keys(card.notes).length; card.notes[`n${idx}`] = { note: val }; break; } case "NICKNAME": { if (!card.nicknames) card.nicknames = {}; card.nicknames.n0 = { name: val }; break; } case "UID": card.uid = val; break; case "KIND": { const k = val.toLowerCase(); if (k === "group" || k === "individual" || k === "org") { card.kind = k; } break; } case "MEMBER": { if (!card.members) card.members = {}; const memberUri = val.startsWith("urn:uuid:") ? val.substring(9) : val; 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 = {}; 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(";"); const sexCode = gParts[0]?.toUpperCase(); const identityText = gParts[1]; if (sexCode || identityText) { card.speakToAs = {}; if (sexCode) { card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode); } if (identityText) { card.speakToAs.pronouns = { p0: { pronouns: identityText } }; } } 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; } } } const hasName = card.name && card.name.components.length > 0; const hasEmail = card.emails && Object.keys(card.emails).length > 0; if (!hasName && !hasEmail && card.kind !== "group") return null; return card; } export function generateVCard(contacts: ContactCard[]): string { return contacts.map(generateSingleVCard).join("\r\n"); } function generateSingleVCard(contact: ContactCard): string { const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"]; if (contact.uid) { 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 || ""; const prefix = components.find(c => c.kind === "prefix")?.value || ""; const suffix = components.find(c => c.kind === "suffix")?.value || ""; const additional = components.find(c => c.kind === "additional")?.value || ""; const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || ""; if (fn) { lines.push(`FN:${encodeValue(fn)}`); lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); } if (contact.nicknames) { for (const nick of Object.values(contact.nicknames)) { lines.push(`NICKNAME:${encodeValue(nick.name)}`); } } if (contact.emails) { for (const email of Object.values(contact.emails)) { const type = contextToType(email.contexts); const typeParam = type ? `;TYPE=${type}` : ""; lines.push(`EMAIL${typeParam}:${email.address}`); } } if (contact.phones) { for (const phone of Object.values(contact.phones)) { 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}`); } } if (contact.organizations) { for (const org of Object.values(contact.organizations)) { const parts = [org.name || ""]; if (org.units) parts.push(...org.units.map(u => u.name)); lines.push(`ORG:${parts.map(encodeValue).join(";")}`); } } 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); const typeParam = type ? `;TYPE=${type}` : ""; let street = addr.street || ""; let locality = addr.locality || ""; let region = addr.region || ""; let postcode = addr.postcode || ""; let country = addr.country || ""; // RFC 9553 components-based address: extract flat fields for vCard ADR if (addr.components && addr.components.length > 0) { const findComp = (kind: string) => addr.components!.filter(c => c.kind === kind).map(c => c.value).join(' '); const number = findComp('number'); const name = findComp('name'); street = street || [number, name].filter(Boolean).join(' '); locality = locality || findComp('locality'); region = region || findComp('region'); postcode = postcode || findComp('postcode'); country = country || findComp('country'); } const parts = [ "", "", street, locality, region, postcode, country, ]; lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`); } } if (contact.anniversaries) { for (const ann of Object.values(contact.anniversaries)) { const dateStr = anniversaryDateToVcardString(ann.date); if (ann.kind === "birth") { lines.push(`BDAY:${dateStr}`); } else if (ann.kind === "wedding") { lines.push(`ANNIVERSARY:${dateStr}`); } else if (ann.kind === "death") { lines.push(`DEATHDATE:${dateStr}`); } } } 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)}`); } } if (contact.members) { for (const memberId of Object.keys(contact.members)) { if (contact.members[memberId]) { lines.push(`MEMBER:urn:uuid:${memberId}`); } } } 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.speakToAs) { const sex = contact.speakToAs.grammaticalGender ? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender) : ""; const pronouns = contact.speakToAs.pronouns; const identity = pronouns ? Object.values(pronouns)[0]?.pronouns || "" : ""; if (sex || 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"); } export function detectDuplicates( existing: ContactCard[], incoming: ContactCard[] ): Map { const dupes = new Map(); const existingEmails = new Map(); for (const c of existing) { if (c.emails) { for (const e of Object.values(c.emails)) { existingEmails.set(e.address.toLowerCase(), c.id); } } } incoming.forEach((card, idx) => { if (card.emails) { for (const e of Object.values(card.emails)) { const match = existingEmails.get(e.address.toLowerCase()); if (match) { dupes.set(idx, match); return; } } } }); return dupes; }