import type { ApiContact } from "@/lib/api/types" import type { FullContact } from "./types" import { fullContactDisplayName } from "./types" export function normalizeContactSearchText(value: string): string { return value .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .trim() } function queryTokens(query: string): string[] { return normalizeContactSearchText(query).split(/\s+/).filter(Boolean) } /** Score 0-1. Strict substring match only (no fuzzy). Higher = closer match. */ export function fieldMatchScore(haystack: string, needle: string): number { const h = normalizeContactSearchText(haystack) const n = normalizeContactSearchText(needle) if (!n || !h.includes(n)) return 0 if (h === n) return 1 if (h.startsWith(n)) { return 0.95 + 0.05 * (n.length / h.length) } for (const word of h.split(/[\s@._+-]+/)) { if (!word) continue if (word.startsWith(n)) { return 0.88 + 0.07 * (n.length / word.length) } } const idx = h.indexOf(n) const positionBonus = 1 - (idx / Math.max(h.length, 1)) * 0.35 const lengthBonus = n.length / Math.max(h.length, 1) return 0.42 + 0.28 * positionBonus + 0.22 * lengthBonus } function scoreAgainstFields(fields: string[], query: string): number { const tokens = queryTokens(query) if (tokens.length === 0) return 0 let total = 0 for (const token of tokens) { let best = 0 for (const field of fields) { if (!field) continue best = Math.max(best, fieldMatchScore(field, token)) best = Math.max(best, fieldMatchScore(field, query)) } if (best === 0) return 0 total += best } return total / tokens.length } function fullContactSearchFields(contact: FullContact): string[] { const fields = [ fullContactDisplayName(contact), contact.firstName, contact.lastName, contact.middleName, contact.company, contact.department, contact.jobTitle, contact.website, contact.notes, ...(contact.nicknames ?? []), ...contact.emails.map((e) => e.value), ...contact.phones.map((p) => p.value), ...(contact.addresses ?? []).flatMap((a) => [ a.street, a.city, a.region, a.postalCode, a.country, ]), ] return fields.filter((value): value is string => Boolean(value?.trim())) } function apiContactSearchFields(contact: ApiContact): string[] { return [contact.full_name, contact.email, contact.phone, contact.org].filter( (value): value is string => Boolean(value?.trim()) ) } export function scoreFullContact(contact: FullContact, query: string): number { return scoreAgainstFields(fullContactSearchFields(contact), query) } export function scoreApiContact(contact: ApiContact, query: string): number { return scoreAgainstFields(apiContactSearchFields(contact), query) } export function rankFullContacts(contacts: FullContact[], query: string): FullContact[] { const trimmed = query.trim() if (!trimmed) return contacts return contacts .map((contact) => ({ contact, score: scoreFullContact(contact, trimmed) })) .filter((entry) => entry.score > 0) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score const nameA = fullContactDisplayName(a.contact) || a.contact.emails[0]?.value || "" const nameB = fullContactDisplayName(b.contact) || b.contact.emails[0]?.value || "" return nameA.localeCompare(nameB, "fr") }) .map((entry) => entry.contact) } export function rankApiContacts(contacts: ApiContact[], query: string): ApiContact[] { const trimmed = query.trim() if (!trimmed) return contacts return contacts .map((contact) => ({ contact, score: scoreApiContact(contact, trimmed) })) .filter((entry) => entry.score > 0) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score return a.contact.full_name.localeCompare(b.contact.full_name, "fr") }) .map((entry) => entry.contact) }