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.
321 lines
9.1 KiB
TypeScript
321 lines
9.1 KiB
TypeScript
import { parseDisplayNameToNameParts } from "./find-contact"
|
|
import { parseVCardPhoto } from "@/lib/contact-avatar"
|
|
import type { FullContact } from "./types"
|
|
|
|
export type ContactImportInput = Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
|
|
|
function stripQuotes(value: string): string {
|
|
const t = value.trim()
|
|
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
return t.slice(1, -1).trim()
|
|
}
|
|
return t
|
|
}
|
|
|
|
function parseCsvLine(line: string): string[] {
|
|
const fields: string[] = []
|
|
let current = ""
|
|
let inQuotes = false
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i]
|
|
if (ch === '"') {
|
|
if (inQuotes && line[i + 1] === '"') {
|
|
current += '"'
|
|
i++
|
|
} else {
|
|
inQuotes = !inQuotes
|
|
}
|
|
} else if (ch === "," && !inQuotes) {
|
|
fields.push(stripQuotes(current))
|
|
current = ""
|
|
} else {
|
|
current += ch
|
|
}
|
|
}
|
|
fields.push(stripQuotes(current))
|
|
return fields
|
|
}
|
|
|
|
function unfoldVcardLines(text: string): string[] {
|
|
const raw = text.split(/\r?\n/)
|
|
const lines: string[] = []
|
|
for (const line of raw) {
|
|
if ((line.startsWith(" ") || line.startsWith("\t")) && lines.length > 0) {
|
|
lines[lines.length - 1] += line.slice(1)
|
|
} else {
|
|
lines.push(line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
function parseVcardProperty(line: string): { key: string; rawKey: string; value: string } | null {
|
|
const idx = line.indexOf(":")
|
|
if (idx === -1) return null
|
|
const rawKey = line.slice(0, idx)
|
|
const value = line.slice(idx + 1).trim()
|
|
const key = rawKey.split(";")[0].toUpperCase()
|
|
return { key, rawKey, value }
|
|
}
|
|
|
|
function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
|
let firstName = ""
|
|
let lastName = ""
|
|
let company: string | undefined
|
|
let jobTitle: string | undefined
|
|
const emails: { value: string; label: string }[] = []
|
|
const phones: { value: string; label: string }[] = []
|
|
let notes: string | undefined
|
|
let avatarUrl: string | undefined
|
|
|
|
for (const line of lines) {
|
|
const prop = parseVcardProperty(line)
|
|
if (!prop || !prop.value) continue
|
|
const { key, rawKey, value } = prop
|
|
|
|
switch (key) {
|
|
case "FN": {
|
|
const parts = parseDisplayNameToNameParts(value)
|
|
if (!firstName && !lastName) {
|
|
firstName = parts.firstName
|
|
lastName = parts.lastName
|
|
}
|
|
break
|
|
}
|
|
case "N": {
|
|
const segments = value.split(";")
|
|
lastName = segments[0]?.trim() ?? ""
|
|
firstName = segments[1]?.trim() ?? ""
|
|
break
|
|
}
|
|
case "EMAIL":
|
|
emails.push({ value, label: "personal" })
|
|
break
|
|
case "TEL":
|
|
phones.push({ value, label: "mobile" })
|
|
break
|
|
case "ORG": {
|
|
const [co, title] = value.split(";")
|
|
company = co?.trim() || undefined
|
|
jobTitle = title?.trim() || undefined
|
|
break
|
|
}
|
|
case "NOTE":
|
|
notes = value
|
|
break
|
|
case "PHOTO":
|
|
avatarUrl = parseVCardPhoto(rawKey, value) ?? avatarUrl
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!firstName && !lastName && emails.length === 0 && phones.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
company,
|
|
jobTitle,
|
|
emails,
|
|
phones,
|
|
notes,
|
|
avatarUrl,
|
|
}
|
|
}
|
|
|
|
export function parseVCardText(text: string): ContactImportInput[] {
|
|
const unfolded = unfoldVcardLines(text)
|
|
const contacts: ContactImportInput[] = []
|
|
let block: string[] = []
|
|
let inCard = false
|
|
|
|
for (const line of unfolded) {
|
|
const upper = line.trim().toUpperCase()
|
|
if (upper === "BEGIN:VCARD") {
|
|
inCard = true
|
|
block = []
|
|
continue
|
|
}
|
|
if (upper === "END:VCARD") {
|
|
if (inCard) {
|
|
const parsed = parseVcardBlock(block)
|
|
if (parsed) contacts.push(parsed)
|
|
}
|
|
inCard = false
|
|
block = []
|
|
continue
|
|
}
|
|
if (inCard) block.push(line)
|
|
}
|
|
|
|
return contacts
|
|
}
|
|
|
|
function headerIndex(headers: string[], candidates: string[]): number {
|
|
const lower = headers.map((h) => h.toLowerCase().trim())
|
|
for (const c of candidates) {
|
|
const i = lower.indexOf(c)
|
|
if (i >= 0) return i
|
|
}
|
|
for (let i = 0; i < lower.length; i++) {
|
|
if (candidates.some((c) => lower[i].includes(c))) return i
|
|
}
|
|
return -1
|
|
}
|
|
|
|
export function parseCsvText(text: string): ContactImportInput[] {
|
|
const lines = text.split(/\r?\n/).filter((l) => l.trim())
|
|
if (lines.length === 0) return []
|
|
|
|
const firstFields = parseCsvLine(lines[0])
|
|
const nameIdx = headerIndex(firstFields, ["name", "nom", "full name", "display name"])
|
|
const emailIdx = headerIndex(firstFields, ["email", "e-mail", "mail"])
|
|
const phoneIdx = headerIndex(firstFields, ["phone", "telephone", "tel", "mobile"])
|
|
const firstIdx = headerIndex(firstFields, ["first name", "prénom", "prenom", "firstname"])
|
|
const lastIdx = headerIndex(firstFields, ["last name", "nom", "lastname"])
|
|
const companyIdx = headerIndex(firstFields, ["company", "organisation", "organization", "entreprise"])
|
|
|
|
const hasHeader = nameIdx >= 0 || emailIdx >= 0 || phoneIdx >= 0 || firstIdx >= 0
|
|
const dataLines = hasHeader ? lines.slice(1) : lines
|
|
|
|
const contacts: ContactImportInput[] = []
|
|
|
|
for (const line of dataLines) {
|
|
const fields = parseCsvLine(line)
|
|
if (fields.every((f) => !f.trim())) continue
|
|
|
|
let firstName = ""
|
|
let lastName = ""
|
|
let company: string | undefined
|
|
|
|
if (hasHeader) {
|
|
if (firstIdx >= 0) firstName = fields[firstIdx]?.trim() ?? ""
|
|
if (lastIdx >= 0) lastName = fields[lastIdx]?.trim() ?? ""
|
|
if (nameIdx >= 0 && !firstName && !lastName) {
|
|
const parts = parseDisplayNameToNameParts(fields[nameIdx] ?? "")
|
|
firstName = parts.firstName
|
|
lastName = parts.lastName
|
|
}
|
|
if (companyIdx >= 0) company = fields[companyIdx]?.trim() || undefined
|
|
} else if (fields.length === 1) {
|
|
const entry = fields[0]
|
|
if (entry.includes("@")) {
|
|
contacts.push({
|
|
firstName: "",
|
|
lastName: "",
|
|
emails: [{ value: entry.trim(), label: "personal" }],
|
|
phones: [],
|
|
})
|
|
continue
|
|
}
|
|
const parts = parseDisplayNameToNameParts(entry)
|
|
firstName = parts.firstName
|
|
lastName = parts.lastName
|
|
} else {
|
|
const parts = parseDisplayNameToNameParts(fields[0] ?? "")
|
|
firstName = parts.firstName
|
|
lastName = parts.lastName
|
|
}
|
|
|
|
const emails =
|
|
hasHeader && emailIdx >= 0 && fields[emailIdx]?.trim()
|
|
? [{ value: fields[emailIdx].trim(), label: "personal" }]
|
|
: !hasHeader && fields[1]?.includes("@")
|
|
? [{ value: fields[1].trim(), label: "personal" }]
|
|
: []
|
|
|
|
const phones =
|
|
hasHeader && phoneIdx >= 0 && fields[phoneIdx]?.trim()
|
|
? [{ value: fields[phoneIdx].trim(), label: "mobile" }]
|
|
: !hasHeader && fields[2]?.trim()
|
|
? [{ value: fields[2].trim(), label: "mobile" }]
|
|
: !hasHeader && fields[1]?.trim() && !fields[1].includes("@")
|
|
? [{ value: fields[1].trim(), label: "mobile" }]
|
|
: []
|
|
|
|
if (!firstName && !lastName && emails.length === 0 && phones.length === 0) continue
|
|
|
|
contacts.push({ firstName, lastName, company, emails, phones })
|
|
}
|
|
|
|
return contacts
|
|
}
|
|
|
|
/** Parse one bulk line: "Name", "email", or "Name <email>". */
|
|
export function parseBulkContactLine(entry: string): ContactImportInput | null {
|
|
const trimmed = entry.trim()
|
|
if (!trimmed) return null
|
|
|
|
const emailMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/)
|
|
const email = emailMatch
|
|
? emailMatch[2].trim()
|
|
: trimmed.includes("@")
|
|
? trimmed
|
|
: ""
|
|
const namePart = emailMatch
|
|
? emailMatch[1].trim()
|
|
: email && !trimmed.includes("@")
|
|
? ""
|
|
: trimmed
|
|
|
|
const { firstName, lastName } = parseDisplayNameToNameParts(namePart)
|
|
|
|
if (!firstName && !lastName && !email) return null
|
|
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
emails: email ? [{ value: email, label: "personal" }] : [],
|
|
phones: [],
|
|
}
|
|
}
|
|
|
|
/** Split bulk text by commas or newlines (respecting quoted segments). */
|
|
export function parseBulkContactText(text: string): ContactImportInput[] {
|
|
const entries: string[] = []
|
|
let current = ""
|
|
let inQuotes = false
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i]
|
|
if (ch === '"') {
|
|
inQuotes = !inQuotes
|
|
current += ch
|
|
} else if ((ch === "," || ch === "\n" || ch === "\r") && !inQuotes) {
|
|
if (current.trim()) entries.push(current.trim())
|
|
current = ""
|
|
if (ch === "\r" && text[i + 1] === "\n") i++
|
|
} else {
|
|
current += ch
|
|
}
|
|
}
|
|
if (current.trim()) entries.push(current.trim())
|
|
|
|
const contacts: ContactImportInput[] = []
|
|
for (const entry of entries) {
|
|
const parsed = parseBulkContactLine(entry)
|
|
if (parsed) contacts.push(parsed)
|
|
}
|
|
return contacts
|
|
}
|
|
|
|
export async function parseContactFile(file: File): Promise<ContactImportInput[]> {
|
|
const text = await file.text()
|
|
const lower = file.name.toLowerCase()
|
|
if (lower.endsWith(".vcf") || lower.endsWith(".vcard")) {
|
|
return parseVCardText(text)
|
|
}
|
|
if (lower.endsWith(".csv")) {
|
|
return parseCsvText(text)
|
|
}
|
|
if (text.includes("BEGIN:VCARD")) {
|
|
return parseVCardText(text)
|
|
}
|
|
return parseCsvText(text)
|
|
}
|