/** Text normalisation helpers shared by entity resolution, search and connectors. */ export function slugify(input: string, maxLength = 96): string { const s = input .normalize('NFKD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/&/g, ' and ') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return s.length > maxLength ? s.slice(0, maxLength).replace(/-+$/g, '') : s; } /** Aggressive normalisation for matching: lowercase, ASCII, punctuation removed, common noise words dropped. */ const NOISE = new Set(['the', 'a', 'an', 'of', 'and', '&', 'card', 'cards', 'lot', 'rare', 'mint', 'nm', 'lp', 'mp', 'hp', 'wow', 'look', 'nice', 'beautiful', 'gorgeous', 'pop', 'gem', 'mt', 'l@@k']); export function normalizeForMatch(input: string): string { return input .normalize('NFKD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[#№]/g, ' ') .replace(/[^a-z0-9./\- ]+/g, ' ') .split(/\s+/) .filter((t) => t && !NOISE.has(t)) .join(' ') .trim(); } export function tokens(input: string): string[] { return normalizeForMatch(input).split(' ').filter(Boolean); } /** Jaccard similarity of token sets (0–1). */ export function jaccard(a: string, b: string): number { const A = new Set(tokens(a)); const B = new Set(tokens(b)); if (A.size === 0 && B.size === 0) return 1; let inter = 0; for (const t of A) if (B.has(t)) inter++; return inter / (A.size + B.size - inter); } export function truncate(s: string, n: number): string { return s.length <= n ? s : `${s.slice(0, n - 1)}…`; } export function compactWhitespace(s: string): string { return s.replace(/\s+/g, ' ').trim(); } /** Extract a 4-digit year plausible for collectibles (1800–current+1). */ export function extractYear(s: string): number | null { const now = new Date().getUTCFullYear() + 1; const m = s.match(/\b(1[89]\d{2}|20\d{2})\b/g); if (!m) return null; for (const y of m) { const n = Number(y); if (n >= 1800 && n <= now) return n; } return null; }