Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Move mail, compose, contacts, and accounts off mocks onto REST + WS. Add client, auth store, IDB-backed query cache, offline queue, and sync bar; hybrid Zustand for UI-only state. Settings still local until backend has preferences API.
159 lines
4.4 KiB
TypeScript
159 lines
4.4 KiB
TypeScript
import type { ApiContact } from './types'
|
|
import type { FullContact, ContactAddress } from '@/lib/contacts/types'
|
|
|
|
interface VCardFields {
|
|
fn?: string
|
|
emails: { value: string; type: string }[]
|
|
phones: { value: string; type: string }[]
|
|
org?: string
|
|
title?: string
|
|
bday?: string
|
|
note?: string
|
|
nickname?: string
|
|
addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[]
|
|
}
|
|
|
|
function parseVCard(raw: string): VCardFields {
|
|
const fields: VCardFields = { emails: [], phones: [], addresses: [] }
|
|
|
|
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 '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
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
firstName,
|
|
lastName,
|
|
emails,
|
|
phones,
|
|
addresses,
|
|
company: vcard?.org ?? api.org,
|
|
jobTitle: vcard?.title,
|
|
birthday,
|
|
notes: vcard?.note,
|
|
nicknames: vcard?.nickname ? [vcard.nickname] : undefined,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
export function fullContactToApiContact(contact: FullContact): Partial<ApiContact> {
|
|
return {
|
|
uid: contact.id,
|
|
full_name: `${contact.firstName} ${contact.lastName}`.trim(),
|
|
email: contact.emails[0]?.value,
|
|
phone: contact.phones[0]?.value,
|
|
org: contact.company,
|
|
}
|
|
}
|