ultisuite-client/lib/api/adapters.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

478 lines
14 KiB
TypeScript

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<string, string> = {
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<ApiContact> {
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<ApiContact> {
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
}