ultisuite-client/lib/api/adapters.ts
R3D347HR4Y c87670e90f
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
feat(api): offline-first mail sync w/ TanStack Query
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.
2026-05-23 00:04:28 +02:00

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,
}
}