import { parseDisplayNameToNameParts } from "./find-contact" import type { FullContact } from "./types" export type ContactImportInput = Omit 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 ". */ 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 { 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) }