import { useEffect, useMemo, useState } from 'react' import { ArrowUpRight, Search, X } from 'lucide-react' import './AddPersonPanel.css' import './SearchSourcesPanel.css' import PzBusy from './PzBusy.jsx' import { t, dateLocale } from '../i18n/index.js' import { fetchSearchSources, searchSource } from '../api/searchSourcesApi.js' import { SEARCH_LINKS, SEARCH_LINK_REGIONS } from '../data/searchLinks.js' import { changesFromResult, personFromResult } from '../utils/searchResultChanges.js' import { fullNameLabel } from '../utils/personName.js' import Skeleton from './Skeleton.jsx' import PortraitThumb from './PortraitThumb.jsx' // Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche const SOURCE_INFO = { deces: { name: t('Décès en France depuis 1970'), detail: t("Fichier de l'INSEE, par matchID. Date et lieu de décès, rien avant 1970. Une femme se cherche sous son nom de naissance."), sent: t("Envoie le nom, le prénom et l'année de naissance à matchid.io"), short: t("Fichier de l'INSEE, par matchID"), }, wikitree: { name: 'WikiTree', detail: t('Un arbre mondial tenu par ses membres. Profils publics seulement.'), sent: t("Envoie le nom, le prénom et l'année de naissance à wikitree.com"), short: t('Arbre tenu par ses membres'), }, } // « 1898 », « 1898-03 » ou « 1898-03-14 » dans la langue de l'interface function formatDate(value) { if (!value) return '' const [year, month, day] = value.split('-').map(Number) if (!month) return String(year) const date = new Date(Date.UTC(year, month - 1, day || 1)) const options = day ? { day: 'numeric', month: 'long', year: 'numeric' } : { month: 'long', year: 'numeric' } const text = date.toLocaleDateString(dateLocale, { ...options, timeZone: 'UTC' }) // « 1er janvier », pas « 1 janvier » return dateLocale.startsWith('fr') && day === 1 ? text.replace(/^1 /, '1er ') : text } // « Né le 24 août 1898 à Monségur (40) », accordé au sexe quand la source le donne const VERBS = { birth: { M: (when) => t('Né {when}', { when }), F: (when) => t('Née {when}', { when }), null: (when) => t('Né(e) {when}', { when }) }, death: { M: (when) => t('Mort {when}', { when }), F: (when) => t('Morte {when}', { when }), null: (when) => t('Mort(e) {when}', { when }) }, } function eventSentence(kind, event, sex) { if (!event?.date && !event?.place) return null const parts = [] if (event.date) parts.push(event.date.length === 10 ? t('le {date}', { date: formatDate(event.date) }) : t('en {date}', { date: formatDate(event.date) })) if (event.place) parts.push(t('à {place}', { place: event.place })) return (VERBS[kind][sex] || VERBS[kind].null)(parts.join(' ')) } // « 1898 – 1987, 89 ans au décès » ; l'âge seulement quand les deux dates sont complètes function lifespan(result) { const birth = result.birth?.date const death = result.death?.date // Une seule date : la phrase « Né le … » la donne déjà if (!birth || !death) return '' const years = `${birth.slice(0, 4)} – ${death.slice(0, 4)}` if (birth.length !== 10 || death.length !== 10) return years const age = Number(death.slice(0, 4)) - Number(birth.slice(0, 4)) - (death.slice(5) < birth.slice(5) ? 1 : 0) return `${years}, ${t('{age} ans au décès', { age })}` } // Pourquoi « Correspond bien » ou « À comparer » : l'année et le lieu cherchés, face à ceux du résultat function matchReason(result, query) { const reasons = [] const year = Number(result.birth?.date?.slice(0, 4)) || null if (query.birthYear) { if (!year) reasons.push(t("pas d'année de naissance")) else reasons.push(Math.abs(year - query.birthYear) <= 1 ? t('même année') : t('autre année')) } if (query.place) { if (!result.birth?.place) reasons.push(t('pas de lieu de naissance')) else reasons.push(plain(result.birth.place).startsWith(plain(query.place)) ? t('même lieu') : t('autre lieu de naissance')) } return reasons.length ? reasons.join(', ') : t('seuls le nom et le prénom sont comparés') } function personYears(person) { const birth = person.birthYear || person.birthDate?.slice(0, 4) const death = person.deathYear || person.deathDate?.slice(0, 4) return [birth, death].filter(Boolean).join(' – ') } // Titre traduit : le passage entre et garde son italique (comme « Ajouter quelqu'un ») function withEmphasis(text) { const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) return <>{before}{emphasized}{after} } // Mêmes libellés que « Ajouter quelqu'un » : la nouvelle personne est parent, enfant… de quelqu'un de l'arbre const RELATION_TABS = { parent: t('Parent'), child: t('Enfant'), spouse: t('Conjoint·e'), sibling: t('Frère·Sœur'), } const RELATION_LABELS = { parent: t('Parent de'), child: t('Enfant de'), spouse: t('Conjoint·e de'), sibling: t('Frère·Sœur de'), } const plain = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim() // proposes : la famille ajoute en proposition, le propriétaire de l'arbre valide ensuite function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = false, proposes = false, onApply, onCreate, createError = '', onClose }) { const [lastName, setLastName] = useState('') const [firstName, setFirstName] = useState('') const [birthYear, setBirthYear] = useState('') const [place, setPlace] = useState('') // Sources allumées sur ce serveur, lues à la première ouverture pour l'arbre affiché (null : pas encore lues) const [loadedSources, setLoadedSources] = useState({ treeId: null, list: null }) const sources = loadedSources.treeId === treeId ? loadedSources.list : null const [checked, setChecked] = useState([]) const [results, setResults] = useState({}) const [searching, setSearching] = useState(false) const [region, setRegion] = useState('fr') const [picker, setPicker] = useState(null) const [applied, setApplied] = useState({}) // Après une recherche, le formulaire se replie en une ligne : les résultats passent devant const [searched, setSearched] = useState(null) const [editing, setEditing] = useState(true) useEffect(() => { if (!visible || !treeId || !token || loadedSources.treeId === treeId) return let cancelled = false fetchSearchSources(treeId, token) .then((list) => { if (cancelled) return const known = list.filter((id) => SOURCE_INFO[id]) setLoadedSources({ treeId, list: known }) setChecked(known) }) .catch(() => { if (!cancelled) setLoadedSources({ treeId, list: [] }) }) return () => { cancelled = true } }, [visible, treeId, token, loadedSources.treeId]) useEffect(() => { if (!visible) return const handleKeyDown = (event) => { if (event.key === 'Escape') onClose?.() } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [visible, onClose]) const query = useMemo(() => ({ firstName: firstName.trim(), lastName: lastName.trim(), birthYear: /^\d{4}$/.test(birthYear.trim()) ? Number(birthYear.trim()) : undefined, place: place.trim() || undefined, }), [firstName, lastName, birthYear, place]) const canSearch = Boolean(query.firstName && query.lastName && checked.length && !searching) const handleSearch = async (event) => { event.preventDefault() if (!canSearch) return setSearching(true) setSearched(query) setEditing(false) setPicker(null) setResults(Object.fromEntries(checked.map((id) => [id, { status: 'loading', items: [] }]))) await Promise.all(checked.map(async (id) => { try { const items = await searchSource(treeId, id, query, token) setResults((prev) => ({ ...prev, [id]: { status: 'done', items } })) } catch (error) { const status = error?.payload?.error === 'source_quota' ? 'quota' : 'error' setResults((prev) => ({ ...prev, [id]: { status, items: [] } })) } })) setSearching(false) } const toggleSource = (id) => setChecked((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) // Personnes de l'arbre proposées pour un résultat : même nom de famille d'abord, puis l'année de naissance la plus proche const candidates = useMemo(() => { if (!picker) return [] const needle = plain(picker.filter) const year = Number(picker.result.birth?.date?.slice(0, 4)) || null return persons .filter((person) => !needle || plain(`${person.firstName} ${person.lastName} ${person.birthName || ''}`).includes(needle)) .map((person) => { const personYear = Number(person.birthYear || person.birthDate?.slice(0, 4)) || null const gap = year && personYear ? Math.abs(year - personYear) : 50 // Né(e) une autre année que le résultat : on le dit avant qu'on rattache la mauvaise personne return { person, gap, yearOff: Boolean(year && personYear && gap > 2) } }) .sort((a, b) => a.gap - b.gap) .slice(0, 6) }, [picker, persons]) // Le plus souvent, la personne trouvée n'est pas encore dans l'arbre : on la crée, rattachée à un proche const handleCreate = async () => { const { key, result, sourceId, relationType, relative } = picker if (!relative) return setPicker((prev) => ({ ...prev, saving: true, failed: false })) const created = await onCreate?.(personFromResult(result, SOURCE_INFO[sourceId].name, { personId: relative.id, type: relationType })) if (created) { setApplied((prev) => ({ ...prev, [key]: { name: `${result.firstName} ${result.lastName}`.trim(), created: true } })) setPicker(null) } else { setPicker((prev) => ({ ...prev, saving: false, failed: true })) } } const handlePick = async (person) => { if (picker.mode === 'new') { setPicker((prev) => ({ ...prev, relative: person })) return } const { key, result, sourceId } = picker const changes = changesFromResult(person, result, SOURCE_INFO[sourceId].name) if (!changes) { setPicker((prev) => ({ ...prev, nothingNew: fullNameLabel(person) })) return } setPicker((prev) => ({ ...prev, saving: true })) try { await onApply?.(person.id, changes) setApplied((prev) => ({ ...prev, [key]: { name: fullNameLabel(person), created: false } })) setPicker(null) } catch { setPicker((prev) => ({ ...prev, saving: false, failed: true })) } } if (!visible) return null const linkQuery = { firstName: query.firstName, lastName: query.lastName, birthYear: query.birthYear } const links = SEARCH_LINKS.filter((link) => link.region === region) const checkedCount = checked.length return ( ) } export default SearchSourcesPanel