/** * Helpers for the enrichment profile (Wikidata / Wikipedia / homepage / registries): source labels, description * attribution, socials, corporate-structure grouping, leadership merge. Server- and client-safe (no React). */ import type { CompanyCard, CompanyProfile, CompanySocials, Person, ProfileSource, Relationship } from './types'; /** Human label for a provenance source id (`wikidata`, `sec_edgar`, `page`, …). Unknown ids are humanised, never hidden. */ export const SOURCE_LABELS: Record = { wikidata: 'Wikidata', wikipedia: 'Wikipedia', homepage: 'Website', website: 'Website', page: 'Leadership page', leadership_page: 'Leadership page', llm: 'LLM', sec: 'SEC EDGAR', sec_edgar: 'SEC EDGAR', edgar: 'SEC EDGAR', gleif: 'GLEIF', registry: 'Registry', opencorporates: 'OpenCorporates', crunchbase: 'Crunchbase', observed: 'Observed', }; export function sourceLabel(source: string | null | undefined): string { if (!source) return 'Unknown source'; const k = source.toLowerCase(); if (SOURCE_LABELS[k]) return SOURCE_LABELS[k]; const w = k.replace(/[_-]+/g, ' ').trim(); return w.charAt(0).toUpperCase() + w.slice(1); } /** Description + attribution: profile first, then the registry description (unattributed → no attribution line). */ export function descriptionOf(c: CompanyCard): { text: string; source: CompanyProfile['description_source'] | null; url: string | null; license: string | null } | null { const p = c.profile; if (p?.description) return { text: p.description, source: p.description_source, url: p.description_url, license: p.description_license }; if (c.description) return { text: c.description, source: null, url: null, license: null }; return null; } /** Best available logo candidates, in order (profile logo → profile icon → legacy `logo_url`). */ export function logoCandidates(c: { logo_url?: string | null; profile?: CompanyProfile | null }): string[] { const out: string[] = []; for (const u of [c.profile?.logo_url, c.profile?.icon_url, c.logo_url]) if (u && !out.includes(u)) out.push(u); return out; } /** 1–2 letter monogram for the fallback plate ("Dassault Systèmes" → "DS", "Arm" → "A"). */ export function monogram(name: string): string { const words = name .replace(/[^\p{L}\p{N} ]+/gu, ' ') .split(/\s+/) .filter(Boolean); if (!words.length) return '?'; if (words.length === 1) return words[0]!.slice(0, 1).toUpperCase(); return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase(); } export const SOCIAL_LABELS: Record = { linkedin: 'LinkedIn', x: 'X', youtube: 'YouTube', facebook: 'Facebook', instagram: 'Instagram', github: 'GitHub', tiktok: 'TikTok', crunchbase: 'Crunchbase' }; const SOCIAL_ORDER: (keyof CompanySocials)[] = ['linkedin', 'x', 'youtube', 'github', 'instagram', 'facebook', 'tiktok', 'crunchbase']; export function socialsOf(p: CompanyProfile | null | undefined): { key: keyof CompanySocials; label: string; url: string }[] { if (!p?.socials) return []; return SOCIAL_ORDER.filter((k) => typeof p.socials[k] === 'string' && /^https?:\/\//i.test(p.socials[k] as string)).map((k) => ({ key: k, label: SOCIAL_LABELS[k], url: p.socials[k] as string })); } /** Per-field provenance lookup (`sources[]` is a flat list keyed by `field`). */ export function sourceFor(p: CompanyProfile | null | undefined, ...fields: string[]): ProfileSource | null { if (!p?.sources?.length) return null; for (const f of fields) { const s = p.sources.find((x) => x.field === f); if (s) return s; } return null; } /** Wikidata-sourced people first by role weight (CEO → chair → founders → other executives), then page-observed, then name. */ const ROLE_RANK: [RegExp, number][] = [ [/\b(chief executive|ceo|managing director|président-directeur|directeur général)\b/i, 0], [/\b(executive chair|chair(man|woman|person)?|chair of the board|président du conseil)\b/i, 1], [/\b(co-?founder|founder|fondateur|fondatrice)\b/i, 2], [/\b(president|chief \w+ officer|c[a-z]o\b|general counsel)\b/i, 3], [/\b(vp|vice president|head of|director)\b/i, 4], ]; export function roleRank(title: string | null | undefined): number { if (!title) return 6; for (const [re, r] of ROLE_RANK) if (re.test(title)) return r; return 5; } export type MergedPerson = Person & { sources: ('wikidata' | 'page')[] }; /** Merge page-observed and Wikidata-sourced people by normalised name; page metadata wins, both source chips are kept. */ export function mergePeople(list: Person[]): MergedPerson[] { const byKey = new Map(); for (const p of list) { const src = p.source === 'wikidata' ? 'wikidata' : 'page'; const key = p.name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim(); const cur = byKey.get(key); if (!cur) { byKey.set(key, { ...p, sources: [src] }); continue; } if (!cur.sources.includes(src)) cur.sources.push(src); // prefer the page row's identity/title (observed first-party), keep the richer title otherwise if (src === 'page' && cur.source === 'wikidata') Object.assign(cur, { ...p, sources: cur.sources }); else if (!cur.title && p.title) cur.title = p.title; cur.is_executive = cur.is_executive || p.is_executive; } return [...byKey.values()].sort((a, b) => { const ra = roleRank(a.title); const rb = roleRank(b.title); if (ra !== rb) return ra - rb; if (a.is_executive !== b.is_executive) return a.is_executive ? -1 : 1; return a.name.localeCompare(b.name); }); } /** Corporate-structure groups, in display order. `PARENT_OF X` means X is a subsidiary of this company. */ export const STRUCTURE_GROUPS: { id: string; kinds: string[]; label: string; hint: string }[] = [ { id: 'parent', kinds: ['SUBSIDIARY_OF', 'PARENT'], label: 'Parent', hint: 'This company is recorded as a subsidiary of' }, { id: 'owners', kinds: ['OWNED_BY'], label: 'Owners', hint: 'Recorded owners / significant shareholders' }, { id: 'acquired_by', kinds: ['ACQUIRED_BY'], label: 'Acquired by', hint: 'Recorded acquirer' }, { id: 'subsidiaries', kinds: ['PARENT_OF', 'SUBSIDIARY'], label: 'Subsidiaries', hint: 'Companies recorded as subsidiaries' }, { id: 'owns', kinds: ['OWNER_OF'], label: 'Owns', hint: 'Recorded holdings' }, { id: 'acquisitions', kinds: ['ACQUIRED', 'ACQUIRER_OF'], label: 'Acquisitions', hint: 'Companies recorded as acquired' }, ]; export function groupRelationships(rels: Relationship[]): { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] { const used = new Set(); const out: { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] = []; for (const g of STRUCTURE_GROUPS) { const rows = rels.filter((r) => g.kinds.includes((r.kind ?? '').toUpperCase())); if (rows.length) { rows.forEach((r) => used.add(r)); out.push({ group: g, rows: sortRel(rows) }); } } const other = rels.filter((r) => !used.has(r)); if (other.length) out.push({ group: { id: 'other', label: 'Other relationships', hint: 'Competitors, partners and other recorded links', kinds: [] }, rows: sortRel(other) }); return out; } function sortRel(rows: Relationship[]): Relationship[] { // current (no valid_to) first, then most recent, then confidence return [...rows].sort((a, b) => { const ea = a.valid_to ? 1 : 0; const eb = b.valid_to ? 1 : 0; if (ea !== eb) return ea - eb; if ((a.valid_from ?? '') !== (b.valid_from ?? '')) return (b.valid_from ?? '').localeCompare(a.valid_from ?? ''); return b.confidence - a.confidence; }); } export function relationshipName(r: Relationship): string { return r.company?.display_name ?? r.to_name ?? 'Unnamed counterpart'; }