Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
478 lines
14 KiB
TypeScript
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
|
|
}
|