import type { ApiContact } from './types' import type { FullContact, ContactAddress } from '@/lib/contacts/types' import type { ContactBulkEditField } from '@/lib/contacts/bulk-edit-fields' import { avatarUrlToVCardPhotoLine, parseVCardPhoto, } from '@/lib/contact-avatar' interface VCardFields { fn?: string emails: { value: string; type: string }[] phones: { value: string; type: string }[] org?: string title?: string bday?: string note?: string nickname?: string url?: string socialProfiles: { value: string; type: string }[] ultiLabels?: string[] photo?: string addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[] } const VCARD_TYPE_MAP: Record = { travail: 'WORK', work: 'WORK', domicile: 'HOME', home: 'HOME', mobile: 'CELL', cell: 'CELL', autre: 'OTHER', other: 'OTHER', internet: 'INTERNET', personal: 'HOME', } function escapeVCardValue(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\r?\n/g, '\\n') } function vcardTypeParam(label: string): string { const key = label.trim().toLowerCase() return VCARD_TYPE_MAP[key] ?? 'OTHER' } function foldVCardLine(line: string): string { const max = 75 if (line.length <= max) return line const parts: string[] = [line.slice(0, max)] let rest = line.slice(max) while (rest.length > 0) { parts.push(` ${rest.slice(0, max - 1)}`) rest = rest.slice(max - 1) } return parts.join('\r\n') } function vcardSocialTypeParam(label: string): string { const key = label.trim().toLowerCase() if (key === 'x') return 'twitter' return key || 'other' } function socialProfileLabelFromType(type: string): string { const key = type.trim().toLowerCase() if (key === 'x') return 'twitter' return key || 'other' } export function buildVCardFromFullContact(contact: FullContact): string { const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0'] const uid = contact.id?.trim() if (uid) lines.push(`UID:${escapeVCardValue(uid)}`) const fullName = `${contact.firstName} ${contact.lastName}`.trim() lines.push( `N:${escapeVCardValue(contact.lastName)};${escapeVCardValue(contact.firstName)};;;`, ) lines.push(`FN:${escapeVCardValue(fullName || contact.emails[0]?.value || 'Contact')}`) for (const email of contact.emails) { const value = email.value?.trim() if (!value) continue const type = vcardTypeParam(email.label) lines.push(`EMAIL;TYPE=${type}:${escapeVCardValue(value)}`) } for (const phone of contact.phones) { const value = phone.value?.trim() if (!value) continue const type = vcardTypeParam(phone.label) lines.push(`TEL;TYPE=${type}:${escapeVCardValue(value)}`) } if (contact.company?.trim() || contact.department?.trim()) { const orgParts = [contact.company?.trim() ?? '', contact.department?.trim() ?? ''] lines.push(`ORG:${orgParts.map(escapeVCardValue).join(';')}`) } if (contact.jobTitle?.trim()) { lines.push(`TITLE:${escapeVCardValue(contact.jobTitle.trim())}`) } if (contact.website?.trim()) { lines.push(`URL:${escapeVCardValue(contact.website.trim())}`) } for (const profile of contact.socialProfiles ?? []) { const value = profile.value?.trim() if (!value) continue const type = vcardSocialTypeParam(profile.label) lines.push(`X-SOCIALPROFILE;TYPE=${type}:${escapeVCardValue(value)}`) } for (const addr of contact.addresses ?? []) { const hasValue = addr.street?.trim() || addr.city?.trim() || addr.region?.trim() || addr.postalCode?.trim() || addr.country?.trim() if (!hasValue) continue const type = vcardTypeParam(addr.label) const adr = [ '', '', addr.street?.trim() ?? '', addr.city?.trim() ?? '', addr.region?.trim() ?? '', addr.postalCode?.trim() ?? '', addr.country?.trim() ?? '', ] .map(escapeVCardValue) .join(';') lines.push(`ADR;TYPE=${type}:${adr}`) } if (contact.notes?.trim()) { lines.push(`NOTE:${escapeVCardValue(contact.notes.trim())}`) } if (contact.labels?.length) { lines.push( `X-ULTI-LABELS:${contact.labels.map(escapeVCardValue).join(',')}`, ) } const photoLine = avatarUrlToVCardPhotoLine(contact.avatarUrl, escapeVCardValue) if (photoLine) lines.push(photoLine) lines.push('END:VCARD') return lines.map(foldVCardLine).join('\r\n') } function parseVCard(raw: string): VCardFields { const fields: VCardFields = { emails: [], phones: [], addresses: [], socialProfiles: [] } const lines: string[] = [] for (const line of raw.split(/\r?\n/)) { if (/^\s/.test(line) && lines.length > 0) { lines[lines.length - 1] += line.trimStart() } else { lines.push(line) } } for (const line of lines) { const colonIdx = line.indexOf(':') if (colonIdx === -1) continue const rawKey = line.slice(0, colonIdx) const value = line.slice(colonIdx + 1).trim() if (!value) continue const keyParts = rawKey.split(';') const propName = keyParts[0].toUpperCase() const params = keyParts.slice(1).join(';').toUpperCase() const typeMatch = params.match(/TYPE=([^;,]+)/i) const type = typeMatch?.[1]?.toLowerCase() ?? 'other' switch (propName) { case 'FN': fields.fn = value break case 'EMAIL': fields.emails.push({ value, type }) break case 'TEL': fields.phones.push({ value, type }) break case 'ORG': fields.org = value.split(';')[0] break case 'TITLE': fields.title = value break case 'BDAY': { fields.bday = value break } case 'NOTE': fields.note = value break case 'URL': fields.url = value break case 'X-SOCIALPROFILE': case 'SOCIALPROFILE': fields.socialProfiles.push({ value, type: socialProfileLabelFromType(type) }) break case 'NICKNAME': fields.nickname = value break case 'ADR': { const parts = value.split(';') fields.addresses.push({ street: parts[2] || undefined, city: parts[3] || undefined, region: parts[4] || undefined, postalCode: parts[5] || undefined, country: parts[6] || undefined, type, }) break } case 'X-ULTI-LABELS': fields.ultiLabels = value .split(',') .map((s) => s.trim()) .filter(Boolean) break case 'PHOTO': fields.photo = parseVCardPhoto(rawKey, value) ?? fields.photo break } } return fields } function parseBday(raw: string): { day?: number; month?: number; year?: number } | undefined { const m = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/) if (m) { return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) } } const partial = raw.match(/^--(\d{2})-?(\d{2})$/) if (partial) { return { month: Number(partial[1]), day: Number(partial[2]) } } return undefined } function splitName(fullName: string): { firstName: string; lastName: string } { const parts = fullName.trim().split(/\s+/) if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' } return { firstName: parts[0], lastName: parts.slice(1).join(' ') } } export function apiContactToFullContact(api: ApiContact): FullContact { const vcard = api.raw_vcard ? parseVCard(api.raw_vcard) : null const { firstName, lastName } = splitName(vcard?.fn ?? api.full_name ?? '') const emails: { value: string; label: string }[] = vcard?.emails.length ? vcard.emails.map((e) => ({ value: e.value, label: e.type })) : api.email ? [{ value: api.email, label: 'personal' }] : [] const phones: { value: string; label: string }[] = vcard?.phones.length ? vcard.phones.map((p) => ({ value: p.value, label: p.type })) : api.phone ? [{ value: api.phone, label: 'mobile' }] : [] const addresses: ContactAddress[] | undefined = vcard?.addresses.length ? vcard.addresses.map((a) => ({ street: a.street, city: a.city, region: a.region, postalCode: a.postalCode, country: a.country, label: a.type, })) : undefined const birthday = vcard?.bday ? parseBday(vcard.bday) : undefined return { id: api.uid, path: api.path, etag: api.etag, firstName, lastName, emails, phones, addresses, company: vcard?.org ?? api.org, jobTitle: vcard?.title, website: vcard?.url, socialProfiles: vcard?.socialProfiles.length ? vcard.socialProfiles.map((p) => ({ value: p.value, label: p.type })) : undefined, birthday, notes: vcard?.note, nicknames: vcard?.nickname ? [vcard.nickname] : undefined, labels: vcard?.ultiLabels?.length ? vcard.ultiLabels : undefined, avatarUrl: vcard?.photo, createdAt: Date.now(), updatedAt: Date.now(), } } export function fullContactToApiContact(contact: FullContact): Partial { const raw_vcard = buildVCardFromFullContact(contact) const fullName = `${contact.firstName} ${contact.lastName}`.trim() return { uid: contact.id, full_name: fullName || contact.emails[0]?.value || 'Contact', email: contact.emails[0]?.value, phone: contact.phones[0]?.value, org: contact.company, raw_vcard, } } function unfoldVCardLines(raw: string): string[] { const lines: string[] = [] for (const line of raw.split(/\r?\n/)) { if (/^\s/.test(line) && lines.length > 0) { lines[lines.length - 1] += line.trimStart() } else { lines.push(line) } } return lines } function vcardPropName(line: string): string { return line.split(':')[0]?.split(';')[0]?.toUpperCase() ?? '' } function upsertVCardLine( lines: string[], propName: string, lineContent: string | null, ): string[] { const upper = propName.toUpperCase() const kept = lines.filter((line) => vcardPropName(line) !== upper) if (!lineContent) return kept const endIdx = kept.findIndex((l) => l.toUpperCase() === 'END:VCARD') const insertAt = endIdx >= 0 ? endIdx : kept.length kept.splice(insertAt, 0, lineContent) return kept } function readOrgParts(vcard: string): { company: string; department: string } { for (const line of unfoldVCardLines(vcard)) { if (!line.toUpperCase().startsWith('ORG:')) continue const value = line.slice(line.indexOf(':') + 1) const parts = value.split(';') return { company: (parts[0] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'), department: (parts[1] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'), } } return { company: '', department: '' } } /** Patch an existing vCard in-place to avoid losing unparsed fields on bulk edit. */ export function patchVCardBulkField( rawVCard: string, contact: FullContact, field: ContactBulkEditField, value: string, ): string { const trimmed = value.trim() let lines = unfoldVCardLines(rawVCard) switch (field) { case 'company': { const org = readOrgParts(rawVCard) const company = trimmed const department = contact.department?.trim() ?? org.department const orgLine = company || department ? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}` : null lines = upsertVCardLine(lines, 'ORG', orgLine) break } case 'department': { const org = readOrgParts(rawVCard) const company = contact.company?.trim() ?? org.company const department = trimmed const orgLine = company || department ? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}` : null lines = upsertVCardLine(lines, 'ORG', orgLine) break } case 'jobTitle': lines = upsertVCardLine( lines, 'TITLE', trimmed ? `TITLE:${escapeVCardValue(trimmed)}` : null, ) break case 'website': lines = upsertVCardLine( lines, 'URL', trimmed ? `URL:${escapeVCardValue(trimmed)}` : null, ) break case 'notes': lines = upsertVCardLine( lines, 'NOTE', trimmed ? `NOTE:${escapeVCardValue(trimmed)}` : null, ) break } return lines.map(foldVCardLine).join('\r\n') } export function patchVCardPhoto(rawVCard: string, avatarUrl: string | undefined): string { const lines = unfoldVCardLines(rawVCard) const lineContent = avatarUrlToVCardPhotoLine(avatarUrl, escapeVCardValue) const next = upsertVCardLine(lines, 'PHOTO', lineContent) return next.map(foldVCardLine).join('\r\n') } export function patchVCardLabels(rawVCard: string, labelIds: string[] | undefined): string { const lines = unfoldVCardLines(rawVCard) const lineContent = labelIds?.length ? `X-ULTI-LABELS:${labelIds.map(escapeVCardValue).join(',')}` : null const next = upsertVCardLine(lines, 'X-ULTI-LABELS', lineContent) return next.map(foldVCardLine).join('\r\n') } export function buildContactUpdatePayload( existing: ApiContact | undefined, contact: FullContact, opts?: { bulkField?: ContactBulkEditField bulkValue?: string patchLabels?: boolean }, ): Partial { const base = fullContactToApiContact(contact) const raw = existing?.raw_vcard?.trim() if (raw && opts?.bulkField !== undefined) { const patched = patchVCardBulkField(raw, contact, opts.bulkField, opts.bulkValue ?? '') return { ...base, raw_vcard: patched } } if (raw && opts?.patchLabels) { return { ...base, raw_vcard: patchVCardLabels(raw, contact.labels) } } if (raw) { const patched = patchVCardPhoto(raw, contact.avatarUrl) return { ...base, raw_vcard: patched } } return base }