ultisuite-client/lib/contacts/import-parsers.ts
R3D347HR4Y 77f99d8d8a hehe
2026-05-19 00:48:20 +02:00

315 lines
8.9 KiB
TypeScript

import { parseDisplayNameToNameParts } from "./find-contact"
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; value: string } | null {
const idx = line.indexOf(":")
if (idx === -1) return null
const left = line.slice(0, idx)
const value = line.slice(idx + 1).trim()
const key = left.split(";")[0].toUpperCase()
return { key, 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
for (const line of lines) {
const prop = parseVcardProperty(line)
if (!prop || !prop.value) continue
const { key, 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
default:
break
}
}
if (!firstName && !lastName && emails.length === 0 && phones.length === 0) {
return null
}
return {
firstName,
lastName,
company,
jobTitle,
emails,
phones,
notes,
}
}
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)
}