Chercher quelqu'un ailleurs : panneau à droite de l'arbre

Entrée « Quelqu'un ailleurs » dans le menu +. On tape un nom, une année et un lieu.
Liens préremplis vers Gallica, FranceArchives, Mémoire des hommes, Léonore, Geneanet,
Open Archives, WikiTree, FreeBMD et FamilySearch, ouverts par le navigateur.
Le serveur interroge les décès INSEE depuis 1970 (matchID) et WikiTree quand
SEARCH_SOURCES les nomme ; MATCHID_TOKEN au-delà d'une centaine de recherches.
Un résultat ajouté ne remplit que les champs vides, passe la personne à vérifier
et garde la source dans sa note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 17:33:41 +01:00
1 parent 3c9031e6b2
commit bff3f54b00
20 files changed
+1124 -1

No files matched your search

+10
View File
@@ -41,6 +41,16 @@ CORS_ORIGIN=http://localhost
# créez-le juste après le premier démarrage, avant de donner l'adresse.
# ALLOW_REGISTRATION=true
# ── Chercher quelqu'un dans des sources publiques ────────────────────────────
# Sources que le serveur interroge pour le panneau « Quelqu'un ailleurs » (menu +) :
# deces (décès en France depuis 1970, fichier de l'INSEE par matchID), wikitree.
# Vide : aucun nom ne quitte l'instance, le panneau ne propose que des liens ouverts
# par le navigateur. Seuls le prénom, le nom et l'année de naissance sont envoyés.
# SEARCH_SOURCES=deces,wikitree
# Clé gratuite de deces.matchid.io (valable 30 jours) : sans elle, les recherches
# anonymes s'arrêtent après une centaine.
# MATCHID_TOKEN=
# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ──
# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback.
# Détails dans le README, section « Connexion par votre fournisseur d'identité ».
+7
View File
@@ -2,6 +2,13 @@
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
## Unreleased
### Search for someone elsewhere
- "Someone elsewhere" in the "+" menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it.
- The server can query deaths in France since 1970 (INSEE records via matchID) and WikiTree, once listed in `SEARCH_SOURCES`. Each source says what it covers and what is sent. The owner adds a result to a person in the tree: empty fields only, marked as needing checking, source kept in the note.
## 1.4.0 (2026-10-23)
### Family tabs
+16
View File
@@ -59,6 +59,22 @@ Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HT
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
#### Chercher quelqu'un dans des sources publiques
Le menu « + » propose « Quelqu'un ailleurs » : un panneau à droite de l'arbre où l'on tape un nom pour voir ce que les sources publiques disent de cette personne. Les liens vers des sites sans recherche interrogeable d'ici (presse ancienne sur Gallica, registres matricules sur FranceArchives, Geneanet, FamilySearch et quelques autres, la France d'abord) marchent toujours : votre navigateur ouvre le site avec le nom déjà tapé, et rien ne passe par le serveur.
Le serveur peut aussi interroger lui-même deux sources et afficher leurs résultats dans le panneau. Elles restent éteintes tant que vous ne les nommez pas dans `.env` :
```
# deces : décès en France depuis 1970 (fichier de l'INSEE, cherché par matchID)
# wikitree : profils publics de WikiTree
SEARCH_SOURCES=deces,wikitree
# Facultatif, gratuit sur deces.matchid.io : sans clé, les recherches s'arrêtent après une centaine
MATCHID_TOKEN=
```
Seuls le prénom, le nom et l'année de naissance quittent le serveur, et le panneau l'écrit à côté de chaque source. Un résultat ajouté à l'arbre ne remplit que les champs vides, passe la personne « à vérifier » et garde le lien de la source dans sa note. Le propriétaire de l'arbre ajoute directement ; la famille voit les résultats et les liens. Une clé matchID vaut 30 jours : renouvelez-la depuis votre compte matchID.
#### Connexion par votre fournisseur d'identité (OIDC)
*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.*
+16
View File
@@ -65,6 +65,22 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
#### Search for someone in public records
The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server.
The server can also query two sources itself and show the results in the panel. They stay off until you list them in `.env`:
```
# deces: deaths in France since 1970 (INSEE death records, searched through matchID)
# wikitree: WikiTree public profiles
SEARCH_SOURCES=deces,wikitree
# Optional, free from deces.matchid.io: anonymous searches stop after about a hundred
MATCHID_TOKEN=
```
Only the first name, last name and birth year leave the server, and the panel says so next to each source. A result added to the tree only fills empty fields, marks the person as needing checking and keeps the source link in their note. The tree owner adds directly; relatives see the results and the links. A matchID key is valid for 30 days: renew it from your matchID account.
#### Sign in with your identity provider (OIDC)
*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.*
+5
View File
@@ -30,6 +30,11 @@ MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
IMMICH_ALLOWED_API_URLS=""
# Panneau « Quelqu'un ailleurs » : sources interrogées par le serveur (deces, wikitree), vide = aucune.
# MATCHID_TOKEN : clé gratuite de deces.matchid.io, indispensable au-delà d'une centaine de recherches.
SEARCH_SOURCES=""
MATCHID_TOKEN=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
+2
View File
@@ -13,6 +13,7 @@ import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
import { immichRoutes } from './routes/immich.js'
import { searchSourceRoutes } from './routes/search-sources.js'
import { mediaRoutes } from './routes/media.js'
import { oidcRoutes } from './routes/oidc.js'
import { publicRoutes } from './routes/public.js'
@@ -159,6 +160,7 @@ export function createApp() {
app.register(gedcomRoutes)
app.register(visitRoutes)
app.register(immichRoutes)
app.register(searchSourceRoutes)
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
if (error instanceof Prisma.PrismaClientInitializationError) {
+6
View File
@@ -86,6 +86,12 @@ const envSchema = z.object({
OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')),
// À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement
PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')),
// Sources publiques que le panneau « Chercher quelqu'un » interroge depuis le serveur, séparées par des
// virgules : deces (décès en France depuis 1970, INSEE par matchID), wikitree. Vide : aucun nom ne quitte
// l'instance, le panneau ne propose que des liens vers les sites, ouverts par le navigateur.
SEARCH_SOURCES: z.string().default(''),
// Facultatif : jeton matchID (gratuit, après inscription) au-delà d'une centaine de recherches
MATCHID_TOKEN: z.string().trim().default(''),
})
export const env = envSchema.parse(process.env)
+207
View File
@@ -0,0 +1,207 @@
import { env } from '../config/env.js'
// Sources publiques interrogées par le panneau « Chercher quelqu'un » (SEARCH_SOURCES). Chaque source
// reçoit la même question et rend des résultats au même format, que le front affiche sans savoir d'où
// ils viennent. Ajouter une source : un objet de plus dans SOURCES, avec ce qu'elle envoie au service.
export type SearchQuery = {
firstName: string
lastName: string
birthYear?: number
place?: string
}
export type SearchEvent = { date: string | null; place: string | null }
export type SearchResult = {
id: string
firstName: string
lastName: string
sex: 'M' | 'F' | null
birth: SearchEvent
death: SearchEvent
url: string
// good : prénom, nom, année et lieu de naissance concordent
match: 'good' | 'partial'
}
type Source = {
id: string
search: (query: SearchQuery) => Promise<SearchResult[]>
}
export class SearchSourceUnavailableError extends Error {}
const TIMEOUT_MS = 10_000
const MAX_RESULTS = 10
const USER_AGENT = 'Parenthese (+https://parenthese.io)'
export function normalizeName(value: string): string {
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z]+/g, ' ').trim()
}
// « 18980824 » ou « 1898-08-24 » → « 1898-08-24 » ; « 0000-00-00 » ou mois inconnu → année seule
function toIsoDate(raw: unknown): string | null {
const digits = String(raw ?? '').replace(/-/g, '')
if (!/^\d{8}$/.test(digits) || digits.startsWith('0000')) return null
const [year, month, day] = [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)]
if (month === '00') return year
if (day === '00') return `${year}-${month}`
return `${year}-${month}-${day}`
}
function yearOf(date: string | null): number | null {
return date ? Number(date.slice(0, 4)) : null
}
function grade(query: SearchQuery, result: Omit<SearchResult, 'match'>): SearchResult['match'] {
const sameLast = normalizeName(result.lastName) === normalizeName(query.lastName)
const firstNames = normalizeName(result.firstName).split(' ')
const sameFirst = firstNames.includes(normalizeName(query.firstName).split(' ')[0] ?? '')
const birthYear = yearOf(result.birth.date)
const sameYear = !query.birthYear || (birthYear !== null && Math.abs(birthYear - query.birthYear) <= 1)
// Sans lieu, un nom et une année courants ramènent des homonymes : seul le lieu de naissance départage
const samePlace = Boolean(query.place && result.birth.place
&& normalizeName(result.birth.place).startsWith(normalizeName(query.place)))
return sameLast && sameFirst && sameYear && samePlace ? 'good' : 'partial'
}
async function fetchJson(url: URL, headers: Record<string, string> = {}): Promise<unknown> {
let response: Response
try {
response = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) })
} catch {
throw new SearchSourceUnavailableError('unreachable')
}
// matchID répond 401 ou 422 quand la recherche sans jeton est épuisée ou que le jeton a expiré
if (response.status === 401 || response.status === 422) throw new SearchSourceUnavailableError('quota')
if (!response.ok) throw new SearchSourceUnavailableError(`http_${response.status}`)
try {
return await response.json()
} catch {
throw new SearchSourceUnavailableError('invalid_json')
}
}
// matchID demande une recherche par seconde au plus : les appels de toute l'instance passent l'un après l'autre
let matchIdQueue: Promise<unknown> = Promise.resolve()
const MATCHID_INTERVAL_MS = 1100
function throttled<T>(run: () => Promise<T>): Promise<T> {
const next = matchIdQueue.then(run, run)
matchIdQueue = next.then(
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
)
return next
}
type MatchIdLocation = { city?: string | string[]; departmentCode?: string; country?: string }
type MatchIdPerson = {
id: string
name?: { first?: string[] | string; last?: string }
sex?: string
birth?: { date?: string; location?: MatchIdLocation }
death?: { date?: string; location?: MatchIdLocation }
}
function matchIdPlace(location: MatchIdLocation | undefined): string | null {
if (!location) return null
const city = Array.isArray(location.city) ? location.city[0] : location.city
if (!city) return location.country || null
if (location.country && location.country !== 'France') return `${city}, ${location.country}`
return location.departmentCode ? `${city} (${location.departmentCode})` : city
}
// Décès en France depuis 1970 : fichier de l'INSEE, cherché par matchID (deces.matchid.io)
const deces: Source = {
id: 'deces',
search: (query) => throttled(async () => {
const url = new URL('https://deces.matchid.io/deces/api/v1/search')
url.searchParams.set('firstName', query.firstName)
url.searchParams.set('lastName', query.lastName)
if (query.birthYear) url.searchParams.set('birthDate', String(query.birthYear))
url.searchParams.set('size', String(MAX_RESULTS))
const headers: Record<string, string> = env.MATCHID_TOKEN ? { Authorization: `Bearer ${env.MATCHID_TOKEN}` } : {}
const payload = await fetchJson(url, headers) as { response?: { persons?: MatchIdPerson[] } }
const persons = payload?.response?.persons ?? []
return persons.map((person) => {
const first = person.name?.first
const base: Omit<SearchResult, 'match'> = {
id: person.id,
firstName: Array.isArray(first) ? first.join(' ') : first ?? '',
lastName: person.name?.last ?? '',
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
birth: { date: toIsoDate(person.birth?.date), place: matchIdPlace(person.birth?.location) },
death: { date: toIsoDate(person.death?.date), place: matchIdPlace(person.death?.location) },
url: `https://deces.matchid.io/id/${encodeURIComponent(person.id)}`,
}
return { ...base, match: grade(query, base) }
})
}),
}
type WikiTreeMatch = {
Id: number
Name: string
FirstName?: string
MiddleName?: string
LastNameAtBirth?: string
Gender?: string
BirthDate?: string
DeathDate?: string
BirthLocation?: string
DeathLocation?: string
}
// WikiTree : arbre mondial tenu par ses membres, profils publics seulement (api.wikitree.com)
const wikitree: Source = {
id: 'wikitree',
search: async (query) => {
const url = new URL('https://api.wikitree.com/api.php')
url.searchParams.set('action', 'searchPerson')
url.searchParams.set('appId', 'parenthese')
url.searchParams.set('FirstName', query.firstName)
url.searchParams.set('LastName', query.lastName)
if (query.birthYear) {
url.searchParams.set('BirthDate', `${query.birthYear}-01-01`)
url.searchParams.set('dateSpread', '2')
}
url.searchParams.set('limit', String(MAX_RESULTS))
const payload = await fetchJson(url) as [{ matches?: WikiTreeMatch[] }] | undefined
const matches = Array.isArray(payload) ? payload[0]?.matches ?? [] : []
// La recherche de WikiTree est floue (« du Pont » pour Dupont, autres prénoms) : même nom de famille seulement
const wantedLast = normalizeName(query.lastName).replace(/ /g, '')
return matches.filter((match) => normalizeName(match.LastNameAtBirth ?? '').replace(/ /g, '') === wantedLast).map((match) => {
const base: Omit<SearchResult, 'match'> = {
id: String(match.Id),
firstName: [match.FirstName, match.MiddleName].filter(Boolean).join(' '),
lastName: match.LastNameAtBirth ?? '',
sex: match.Gender === 'Male' ? 'M' : match.Gender === 'Female' ? 'F' : null,
birth: { date: toIsoDate(match.BirthDate), place: match.BirthLocation || null },
death: { date: toIsoDate(match.DeathDate), place: match.DeathLocation || null },
url: `https://www.wikitree.com/wiki/${encodeURIComponent(match.Name)}`,
}
return { ...base, match: grade(query, base) }
})
},
}
const SOURCES: Source[] = [deces, wikitree]
// Sources allumées par l'opérateur, dans l'ordre de SOURCES ; un nom inconnu est signalé puis ignoré
export function enabledSearchSources(): string[] {
const wanted = env.SEARCH_SOURCES.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean)
for (const name of wanted) {
if (!SOURCES.some((source) => source.id === name)) console.warn(`SEARCH_SOURCES : source inconnue ignorée : ${name}`)
}
return SOURCES.filter((source) => wanted.includes(source.id)).map((source) => source.id)
}
export async function searchSource(id: string, query: SearchQuery): Promise<SearchResult[]> {
const source = SOURCES.find((candidate) => candidate.id === id)
if (!source) throw new SearchSourceUnavailableError('unknown_source')
const results = await source.search(query)
// Les meilleures concordances d'abord, l'ordre du service ensuite
return results.slice(0, MAX_RESULTS).sort((a, b) => Number(b.match === 'good') - Number(a.match === 'good'))
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { canReadTree } from '../lib/tree-access.js'
import { enabledSearchSources, searchSource, SearchSourceUnavailableError } from '../lib/search-sources.js'
// Panneau « Chercher quelqu'un » : le serveur interroge les sources allumées par l'opérateur
// (SEARCH_SOURCES) au nom d'une personne qui lit l'arbre. Rattaché à un arbre pour ne pas faire de
// l'instance un relais ouvert vers ces services ; rien n'est enregistré, seule la question part.
const paramsSchema = z.object({ id: z.string().min(1), source: z.string().min(1).max(40) })
const querySchema = z.object({
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
birthYear: z.coerce.number().int().min(1500).max(2100).optional(),
place: z.string().trim().max(120).optional(),
})
export const searchSourceRoutes: FastifyPluginAsync = async (app) => {
// Sources que le front peut proposer : rien d'autre que leurs identifiants. Sous /trees comme le reste :
// nginx ne relaie vers l'API que /api, /auth, /trees et /health
app.get('/trees/:id/search-sources', async (request, reply) => {
const params = z.object({ id: z.string().min(1) }).safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
return reply.send({ sources: enabledSearchSources() })
})
app.post(
'/trees/:id/search-sources/:source',
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (request, reply) => {
const params = paramsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
if (!enabledSearchSources().includes(params.data.source)) return reply.code(404).send({ error: 'source_not_enabled' })
const body = querySchema.safeParse(request.body)
if (!body.success) return reply.code(400).send({ error: 'invalid_query' })
try {
const results = await searchSource(params.data.source, body.data)
return reply.send({ results })
} catch (error) {
if (error instanceof SearchSourceUnavailableError) {
request.log.warn({ source: params.data.source, reason: error.message }, 'search source unavailable')
// Quota : l'opérateur peut y remédier (MATCHID_TOKEN), le front le dit autrement qu'une panne
return reply.code(502).send({ error: error.message === 'quota' ? 'source_quota' : 'source_unavailable' })
}
throw error
}
},
)
}
+163
View File
@@ -0,0 +1,163 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
// Panneau « Chercher quelqu'un » : seules les sources allumées par l'opérateur répondent, seulement pour
// quelqu'un qui lit l'arbre, et la réponse de matchID arrive au format commun du front.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: { count: vi.fn().mockResolvedValue(1) },
treeMembership: { findUnique: vi.fn() },
userTreeAccess: { findUnique: vi.fn().mockResolvedValue(null) },
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({ prisma: prismaMock }))
let createApp: () => FastifyInstance
beforeAll(async () => {
process.env.SEARCH_SOURCES = 'deces, inconnue'
process.env.MATCHID_TOKEN = ''
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
createApp = (await import('../src/app.js')).createApp
})
afterEach(() => {
vi.unstubAllGlobals()
})
function userToken(app: FastifyInstance) {
return (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
const MATCHID_PAYLOAD = {
response: {
persons: [
{
id: 'abc123',
name: { first: ['Jean', 'Louis'], last: 'Dupont' },
sex: 'M',
birth: { date: '18980314', location: { city: 'Tulle', departmentCode: '19', country: 'France' } },
death: { date: '19871108', location: { city: 'Limoges', departmentCode: '87', country: 'France' } },
},
{
id: 'def456',
name: { first: ['Jean'], last: 'Dupont' },
sex: 'M',
birth: { date: '18980602', location: { city: 'Liège', country: 'Belgique' } },
death: { date: '19740121', location: { city: 'Brive-la-Gaillarde', departmentCode: '19', country: 'France' } },
},
],
},
}
describe('search sources', () => {
it('lists only the sources the operator enabled', async () => {
const app = createApp()
await app.ready()
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/search-sources', headers: { authorization: `Bearer ${userToken(app)}` } })
expect(response.json()).toEqual({ sources: ['deces'] })
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/search-sources' })
expect(anonymous.statusCode).toBe(401)
})
it('refuses a visitor without access, and a source that is not enabled', async () => {
const app = createApp()
await app.ready()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const anonymous = await app.inject({ method: 'POST', url: '/trees/tree-1/search-sources/deces', payload: { firstName: 'Jean', lastName: 'Dupont' } })
expect(anonymous.statusCode).toBe(401)
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
const stranger = await app.inject({
method: 'POST', url: '/trees/tree-1/search-sources/deces',
headers: { authorization: `Bearer ${userToken(app)}` },
payload: { firstName: 'Jean', lastName: 'Dupont' },
})
expect(stranger.statusCode).toBe(403)
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
const disabled = await app.inject({
method: 'POST', url: '/trees/tree-1/search-sources/wikitree',
headers: { authorization: `Bearer ${userToken(app)}` },
payload: { firstName: 'Jean', lastName: 'Dupont' },
})
expect(disabled.statusCode).toBe(404)
expect(fetchMock).not.toHaveBeenCalled()
})
it('sends only the name and birth year to matchID and returns the common format', async () => {
const app = createApp()
await app.ready()
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(MATCHID_PAYLOAD), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/search-sources/deces',
headers: { authorization: `Bearer ${userToken(app)}` },
payload: { firstName: 'Jean', lastName: 'Dupont', birthYear: 1898, place: 'Tulle' },
})
expect(response.statusCode).toBe(200)
const called = new URL(fetchMock.mock.calls[0][0])
expect(called.origin + called.pathname).toBe('https://deces.matchid.io/deces/api/v1/search')
expect(Object.fromEntries(called.searchParams)).toEqual({ firstName: 'Jean', lastName: 'Dupont', birthDate: '1898', size: '10' })
const { results } = response.json()
expect(results[0]).toEqual({
id: 'abc123',
firstName: 'Jean Louis',
lastName: 'Dupont',
sex: 'M',
birth: { date: '1898-03-14', place: 'Tulle (19)' },
death: { date: '1987-11-08', place: 'Limoges (87)' },
url: 'https://deces.matchid.io/id/abc123',
match: 'good',
})
// Né ailleurs que le lieu cherché : proposé, mais à comparer
expect(results[1].birth.place).toBe('Liège, Belgique')
expect(results[1].match).toBe('partial')
})
it('answers 502 when the service is down', async () => {
const app = createApp()
await app.ready()
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('busy', { status: 503 })))
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/search-sources/deces',
headers: { authorization: `Bearer ${userToken(app)}` },
payload: { firstName: 'Jean', lastName: 'Dupont' },
})
expect(response.statusCode).toBe(502)
expect(response.json()).toEqual({ error: 'source_unavailable' })
})
it('tells apart an exhausted anonymous quota', async () => {
const app = createApp()
await app.ready()
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"message":"temporary anonymous usage expired"}', { status: 422 })))
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/search-sources/deces',
headers: { authorization: `Bearer ${userToken(app)}` },
payload: { firstName: 'Jean', lastName: 'Dupont' },
})
expect(response.json()).toEqual({ error: 'source_quota' })
})
})
+13
View File
@@ -20,6 +20,7 @@ import EditModeOverlay from './components/EditSessionBar.jsx'
import EditConfirmModal from './components/EditConfirmModal'
import DraftRestoreBanner from './components/DraftRestoreBanner'
import AddPersonPanel from './components/AddPersonPanel'
import SearchSourcesPanel from './components/SearchSourcesPanel.jsx'
import ContributionSessionModal from './components/ContributionSessionModal'
import { ContextualNavbar } from './components/navbar'
import ZoomControl from './components/ZoomControl'
@@ -535,6 +536,8 @@ function App() {
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [showDraftBanner, setShowDraftBanner] = useState(false)
const [accountPanelVisible, setAccountPanelVisible] = useState(false)
// Chercher quelqu'un dans des sources publiques (menu « + », groupe Chercher)
const [searchPanelVisible, setSearchPanelVisible] = useState(false)
const [linkedSelfPersonId, setLinkedSelfPersonId] = useState('')
const [relationFeedback, setRelationFeedback] = useState({
loading: false,
@@ -3181,6 +3184,7 @@ function App() {
onEditClick={handleEditToggle}
onAddPersonClick={handleOpenAddPerson}
onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined}
onSearchClick={isFamilyVisitor || userRole.isAdmin ? () => setSearchPanelVisible(true) : undefined}
helpUrl={HELP_URL}
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
onTreeSelect={handleOpenTreeFromAccount}
@@ -3331,6 +3335,15 @@ function App() {
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
/>
<SearchSourcesPanel
visible={searchPanelVisible}
treeId={tree.treeContext.treeId}
token={tree.treeContext.accessToken || auth.userAuth.token}
persons={persons}
canApply={tree.canEditCurrentTree}
onApply={handlePersonSave}
onClose={() => setSearchPanelVisible(false)}
/>
<AccountPanel
visible={accountPanelVisible}
userDisplayName={accountDisplayName}
+18
View File
@@ -0,0 +1,18 @@
import { apiRequest } from './client'
// Sources que ce serveur interroge (SEARCH_SOURCES) : une liste d'identifiants, vide par défaut
export async function fetchSearchSources(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources`, {
headers: { Authorization: `Bearer ${token}` },
})
return Array.isArray(data?.sources) ? data.sources : []
}
export async function searchSource(treeId, sourceId, query, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources/${encodeURIComponent(sourceId)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(query),
})
return Array.isArray(data?.results) ? data.results : []
}
@@ -0,0 +1,84 @@
/* ============================================================
Chercher quelqu'un (DA terracotta)
Panneau flottant à droite, de la même famille qu'« Ajouter quelqu'un » (à gauche) :
la fiche, en bas à gauche, reste visible pendant qu'on cherche.
Sur téléphone, panneau en bas d'écran. Il apparaît et disparaît net, sans glissement.
============================================================ */
.search-panel {
position: fixed;
top: 24px;
right: 24px;
bottom: 24px;
z-index: 1050;
width: 420px;
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 20px 18px;
background: var(--pz-surface);
border: 1px solid var(--pz-line);
border-radius: var(--pz-r-l);
box-shadow: var(--pz-shadow-lg);
}
.sp-form { display: grid; gap: 14px; }
.sp-sources { gap: 4px; }
.sp-sources > .pz-label { margin: 0 0 4px; }
.sp-source { display: flex; gap: 10px; align-items: flex-start; padding: 9px 0; border-bottom: 1px solid var(--pz-line-soft); cursor: pointer; }
.sp-source input { flex: none; width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--pz-a500); }
.sp-source-name { display: block; font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); }
.sp-source-detail { display: block; margin-top: 2px; font: 400 12.5px/1.45 var(--pz-sans); color: var(--pz-muted); text-wrap: pretty; }
.sp-source-sent { display: block; margin-top: 3px; font: 400 12px/1.4 var(--pz-sans); color: var(--pz-a700); }
.sp-submit { display: flex; justify-content: flex-end; padding-top: 10px; }
.sp-results { gap: 8px; }
.sp-results-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.sp-results-title { margin: 0; font: 400 17px/1.25 var(--pz-serif); color: var(--pz-ink); }
.sp-result { padding: 12px; border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); background: var(--pz-surface); }
.sp-result--done { background: var(--pz-success-bg); border-color: transparent; }
.sp-result-name { font: 400 16px/1.25 var(--pz-serif); color: var(--pz-ink); }
.sp-result-name em { font-style: italic; font-weight: 300; }
.sp-result-line { margin: 3px 0 0; font: 400 13px/1.45 var(--pz-sans); color: var(--pz-ink-2); font-variant-numeric: tabular-nums; }
.sp-result-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 10px; }
.sp-result-foot .pz-btn { margin-left: auto; }
.sp-result-link { display: inline-flex; align-items: center; gap: 2px; font-size: 12.5px; }
.sp-result-applied { margin: 8px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-success); }
.sp-match { display: inline-flex; align-items: center; gap: 6px; font: 400 12px var(--pz-sans); color: var(--pz-ink-2); }
.sp-match::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #e0b25a; }
.sp-match--good::before { background: #7fae74; }
.sp-picker { display: grid; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--pz-line-soft); }
.sp-picker-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.sp-picker-list .apn-dropdown-item:disabled { opacity: .5; cursor: default; }
.sp-picker-list .sp-year-off { color: var(--pz-a700); }
.sp-links { gap: 10px; }
.sp-links > .pz-label { margin: 0; }
.sp-links .pz-tab { font-size: 13px; padding: 0 6px; white-space: nowrap; }
.sp-link-list { list-style: none; margin: 0; padding: 0; }
.sp-link { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; min-height: 40px; padding: 9px 0; border-bottom: 1px solid var(--pz-line-soft); text-decoration: none; color: var(--pz-ink); }
.sp-link-list li:last-child .sp-link { border-bottom: 0; }
.sp-link-name { display: inline-flex; align-items: center; gap: 3px; font: 500 14px var(--pz-sans); }
.sp-link-name svg { color: var(--pz-faint); }
.sp-link-detail { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); text-align: right; }
@media (hover: hover) and (pointer: fine) {
.sp-link:hover .sp-link-name { text-decoration: underline; text-decoration-color: var(--pz-a300); text-underline-offset: 3px; }
}
@media (max-width: 768px) {
.search-panel {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
max-height: 90dvh;
border-radius: 26px 26px 0 0;
border-bottom: 0;
padding: 18px 18px calc(16px + env(safe-area-inset-bottom));
}
}
@@ -0,0 +1,315 @@
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 } from '../utils/searchResultChanges.js'
import { fullNameLabel } from '../utils/personName.js'
// 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"),
},
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"),
},
}
// « 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' }
return date.toLocaleDateString(dateLocale, { ...options, timeZone: 'UTC' })
}
function eventLine(event) {
return [formatDate(event?.date), event?.place].filter(Boolean).join(', ')
}
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 <em> et </em> garde son italique (comme « Ajouter quelqu'un »)
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
const plain = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim()
function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = false, onApply, 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({})
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)
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])
const handlePick = async (person) => {
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]: fullNameLabel(person) }))
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 (
<aside className="search-panel" role="dialog" aria-labelledby="searchPanelTitle">
<div className="apn-head">
<div className="pz-modal-head">
<h2 id="searchPanelTitle" className="pz-title">{withEmphasis(t("Chercher <em>quelqu'un</em>"))}</h2>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon apn-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
</div>
<div className="apn-scroll">
<form className="sp-form" onSubmit={handleSearch}>
<div className="pz-row2">
<div className="pz-field">
<label htmlFor="sp-lastName">{t('Nom de naissance')}</label>
<input id="sp-lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} autoComplete="off" />
</div>
<div className="pz-field">
<label htmlFor="sp-firstName">{t('Prénom')}</label>
<input id="sp-firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} autoComplete="off" />
</div>
</div>
<div className="pz-row2">
<div className="pz-field">
<label htmlFor="sp-birthYear">{t('Né(e) vers')}<span className="apn-optional">{t('année')}</span></label>
<input id="sp-birthYear" type="text" inputMode="numeric" maxLength={4} value={birthYear} onChange={(e) => setBirthYear(e.target.value.replace(/\D/g, ''))} autoComplete="off" />
</div>
<div className="pz-field">
<label htmlFor="sp-place">{t('Lieu de naissance')}<span className="apn-optional">{t('facultatif')}</span></label>
<input id="sp-place" type="text" value={place} onChange={(e) => setPlace(e.target.value)} autoComplete="off" />
</div>
</div>
<div className="apn-section sp-sources">
<p className="pz-label">{t('Où chercher')}</p>
{sources === null && <p className="pz-hint">{t('Chargement…')}</p>}
{sources?.length === 0 && (
<p className="pz-hint">{t("Ce serveur n'interroge aucune source : aucun nom ne quitte l'instance. Les liens plus bas ouvrent les sites dans votre navigateur.")}</p>
)}
{sources?.map((id) => {
const info = SOURCE_INFO[id]
return (
<label key={id} className="sp-source">
<input type="checkbox" checked={checked.includes(id)} onChange={() => toggleSource(id)} />
<span>
<span className="sp-source-name">{info.name}</span>
<span className="sp-source-detail">{info.detail}</span>
<span className="sp-source-sent">{info.sent}</span>
</span>
</label>
)
})}
{sources?.length > 0 && (
<div className="sp-submit">
<button type="submit" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!canSearch}>
<PzBusy busy={searching} busyLabel={t('Recherche…')}>{checkedCount > 1 ? t('Chercher dans {n} sources', { n: checkedCount }) : t('Chercher')}</PzBusy>
</button>
</div>
)}
</div>
</form>
{Object.entries(results).map(([sourceId, { status, items }]) => (
<section key={sourceId} className="apn-section sp-results" aria-live="polite">
<div className="sp-results-head">
<h3 className="sp-results-title">{SOURCE_INFO[sourceId].name}</h3>
{status === 'done' && <span className="pz-hint">{items.length === 0 ? t('Rien trouvé') : items.length === 1 ? t('1 trouvé') : t('{n} trouvés', { n: items.length })}</span>}
</div>
{status === 'loading' && <p className="pz-hint">{t('Recherche…')}</p>}
{status === 'error' && <p className="pz-error">{t('La source ne répond pas pour le moment. Réessayez plus tard.')}</p>}
{status === 'quota' && <p className="pz-error">{t("Ce serveur a épuisé ses recherches sans clé. Son administrateur peut ajouter une clé matchID gratuite (MATCHID_TOKEN).")}</p>}
{status === 'done' && items.length === 0 && (
<p className="pz-hint">{t("Personne sous ce nom. Essayez sans l'année, ou avec le nom de naissance.")}</p>
)}
{items.map((result) => {
const key = `${sourceId}:${result.id}`
const isPicking = picker?.key === key
return (
<article key={key} className={`sp-result${applied[key] ? ' sp-result--done' : ''}`}>
<div className="sp-result-name">{result.firstName} <em>{result.lastName}</em></div>
{result.birth?.date || result.birth?.place ? <p className="sp-result-line">{t('Naissance')} : {eventLine(result.birth)}</p> : null}
{result.death?.date || result.death?.place ? <p className="sp-result-line">{t('Décès')} : {eventLine(result.death)}</p> : null}
<div className="sp-result-foot">
<span className={`sp-match${result.match === 'good' ? ' sp-match--good' : ''}`}>
{result.match === 'good' ? t('Correspond bien') : t('À comparer')}
</span>
<a className="pz-link sp-result-link" href={result.url} target="_blank" rel="noopener noreferrer">
{t('Voir la source')}<ArrowUpRight size={13} aria-hidden="true" />
</a>
{canApply && !applied[key] && (
<button type="button" className={`pz-btn pz-btn--sm ${result.match === 'good' ? 'pz-btn--primary' : 'pz-btn--secondary'}`} onClick={() => setPicker(isPicking ? null : { key, result, sourceId, filter: result.lastName })} aria-expanded={isPicking}>
{t("Ajouter à l'arbre")}
</button>
)}
</div>
{applied[key] && <p className="sp-result-applied">{t('Ajouté à {name}, à vérifier', { name: applied[key] })}</p>}
{isPicking && (
<div className="sp-picker">
<div className="pz-field">
<label htmlFor={`sp-pick-${key}`}>{t('À quelle personne de l’arbre ?')}</label>
<div className="apn-search">
<Search size={16} className="apn-search-icon" aria-hidden="true" />
<input id={`sp-pick-${key}`} type="text" value={picker.filter} onChange={(e) => setPicker((prev) => ({ ...prev, filter: e.target.value, nothingNew: null, failed: false }))} autoComplete="off" autoFocus />
</div>
</div>
<ul className="sp-picker-list">
{candidates.length === 0 && <li className="pz-hint">{t('Personne ne correspond à cette recherche.')}</li>}
{candidates.map(({ person, yearOff }) => (
<li key={person.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handlePick(person)} disabled={picker.saving}>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-dropdown-name">{person.firstName} <em>{person.lastName}</em></span>
<span className={`apn-dropdown-date${yearOff ? ' sp-year-off' : ''}`} title={yearOff ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
</button>
</li>
))}
</ul>
{candidates.some((candidate) => candidate.yearOff) && <p className="pz-hint">{t("Une année en couleur n'est pas celle de ce résultat.")}</p>}
{picker.nothingNew && <p className="pz-hint">{t('Rien de nouveau pour {name} : ses dates et ses lieux sont déjà remplis.', { name: picker.nothingNew })}</p>}
{picker.failed && <p className="pz-error">{t("L'ajout n'a pas marché. Réessayez.")}</p>}
<p className="pz-hint">{t('Seuls les champs vides sont remplis. La personne passe « à vérifier », et la source va dans sa note.')}</p>
</div>
)}
</article>
)
})}
</section>
))}
<section className="apn-section sp-links">
<p className="pz-label">{t('Chercher aussi sur')}</p>
<div className="pz-tabs" role="tablist">
{SEARCH_LINK_REGIONS.map((item) => (
<button key={item.id} type="button" role="tab" aria-selected={region === item.id} className={`pz-tab${region === item.id ? ' is-active' : ''}`} onClick={() => setRegion(item.id)}>
{item.label}
</button>
))}
</div>
<ul className="sp-link-list">
{links.map((link) => (
<li key={link.id}>
<a href={link.url(linkQuery)} target="_blank" rel="noopener noreferrer" className="sp-link">
<span className="sp-link-name">{link.name}<ArrowUpRight size={13} aria-hidden="true" /></span>
<span className="sp-link-detail">{link.detail}</span>
</a>
</li>
))}
</ul>
<p className="pz-hint">{t("Ces sites s'ouvrent dans votre navigateur, avec le nom déjà tapé quand ils le permettent. Rien ne passe par Parenthèse.")}</p>
</section>
</div>
</aside>
)
}
export default SearchSourcesPanel
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import {
Brush, ImagePlus, Plus, UserPlus, User, X, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft,
Brush, ImagePlus, Plus, UserPlus, User, X, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search,
} from 'lucide-react'
import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu'
import AvatarMenu from './AvatarMenu'
@@ -45,6 +45,7 @@ function ContextualNavbar({
onEditClick,
onAddPersonClick,
onAddSouvenirClick,
onSearchClick,
helpUrl = '',
onShareClick,
onTreeSelect,
@@ -215,6 +216,10 @@ function ContextualNavbar({
}
if (addItems.length) addGroups.push({ label: t('Ajouter'), items: addItems })
if (fixItems.length) addGroups.push({ label: t('Corriger'), items: fixItems })
// Chercher quelqu'un dans des sources publiques (décès INSEE, archives, presse) : panneau à droite de l'arbre
if (onSearchClick) {
addGroups.push({ label: t('Chercher'), items: [{ key: 'search', icon: <Search {...ICON} />, label: t("Quelqu'un ailleurs"), sub: t('décès depuis 1970, archives, presse'), onSelect: onSearchClick }] })
}
}
const hasAddMenu = addGroups.length > 0
+88
View File
@@ -0,0 +1,88 @@
import { t } from '../i18n/index.js'
// Liens utiles du panneau « Chercher quelqu'un » : des sites sans recherche qu'on peut interroger d'ici.
// Le navigateur ouvre le site, rien ne passe par le serveur de Parenthèse. Quand le site le permet, l'adresse
// porte déjà le nom cherché. Ajouter un lien : un objet de plus, avec son pays et une phrase qui dit ce qu'on
// y trouve.
const enc = encodeURIComponent
export const SEARCH_LINK_REGIONS = [
{ id: 'fr', label: t('France') },
{ id: 'be', label: t('Belgique, Pays-Bas') },
{ id: 'world', label: t('Ailleurs') },
]
export const SEARCH_LINKS = [
{
id: 'gallica',
region: 'fr',
name: t('Presse ancienne'),
detail: t('Gallica, journaux numérisés'),
url: ({ firstName, lastName }) => `https://gallica.bnf.fr/services/engine/search/sru?operation=searchRetrieve&version=1.2&query=${enc(`(gallica adj "${[firstName, lastName].filter(Boolean).join(' ')}") and dc.type all "fascicule"`)}`,
},
{
id: 'francearchives',
region: 'fr',
name: t('Registres matricules, état civil'),
detail: t('FranceArchives, hommes nés de 1847 à 1901'),
url: ({ firstName, lastName }) => `https://francearchives.gouv.fr/fr/basedenoms?q=${enc([lastName, firstName].filter(Boolean).join(' '))}`,
},
{
id: 'mdh',
region: 'fr',
name: t('Morts pour la France'),
detail: t('Mémoire des hommes'),
url: () => 'https://www.memoiredeshommes.defense.gouv.fr/',
},
{
id: 'leonore',
region: 'fr',
name: t("Légion d'honneur"),
detail: t('Base Léonore'),
url: () => 'https://www.leonore.archives-nationales.culture.gouv.fr/',
},
{
id: 'geneanet',
region: 'fr',
name: 'Geneanet',
detail: t('Arbres et relevés'),
url: ({ firstName, lastName }) => `https://www.geneanet.org/fonds/individus/?nom=${enc(lastName)}&prenom=${enc(firstName)}&go=1`,
},
{
id: 'openarchives',
region: 'be',
name: 'Open Archives',
detail: t('État civil et registres'),
url: ({ firstName, lastName }) => `https://www.openarchieven.nl/search.php?lang=fr&name=${enc([firstName, lastName].filter(Boolean).join(' '))}`,
},
{
id: 'wikitree',
region: 'world',
name: 'WikiTree',
detail: t('Arbre mondial tenu par ses membres'),
// La recherche par prénom ne se préremplit pas : la page du nom de famille liste toutes ses fiches
url: ({ lastName }) => (lastName ? `https://www.wikitree.com/genealogy/${enc(lastName.toUpperCase())}` : 'https://www.wikitree.com/'),
},
{
id: 'freebmd',
region: 'world',
name: 'FreeBMD',
detail: t('Angleterre et pays de Galles'),
url: () => 'https://www.freebmd.org.uk/cgi/search.pl',
},
{
id: 'familysearch',
region: 'world',
name: 'FamilySearch',
detail: t('Registres du monde entier, compte gratuit'),
url: ({ firstName, lastName, birthYear }) => {
const params = new URLSearchParams({ 'q.givenName': firstName || '', 'q.surname': lastName || '' })
if (birthYear) {
params.set('q.birthLikeDate.from', String(birthYear))
params.set('q.birthLikeDate.to', String(birthYear))
}
return `https://www.familysearch.org/search/record/results?${params}`
},
},
]
+2
View File
@@ -6,6 +6,7 @@ import contribution from './contribution.js'
import compte from './compte.js'
import photos from './photos.js'
import souvenir from './souvenir.js'
import recherche from './recherche.js'
export default {
...app,
@@ -15,5 +16,6 @@ export default {
...compte,
...photos,
...souvenir,
...recherche,
}
+54
View File
@@ -0,0 +1,54 @@
// Traductions anglaises, clé = texte français exact passé à t().
// Panneau « Chercher quelqu'un » (components/SearchSourcesPanel.jsx, data/searchLinks.js) et son entrée de menu
export default {
'Chercher': 'Search',
"Quelqu'un ailleurs": 'Someone elsewhere',
'décès depuis 1970, archives, presse': 'French death records, archives, old newspapers',
"Chercher <em>quelqu'un</em>": 'Search for <em>someone</em>',
'Né(e) vers': 'Born around',
'année': 'year',
'Où chercher': 'Where to search',
"Ce serveur n'interroge aucune source : aucun nom ne quitte l'instance. Les liens plus bas ouvrent les sites dans votre navigateur.": "This server queries no source: no name leaves the instance. The links below open the sites in your browser.",
'Décès en France depuis 1970': 'Deaths in France since 1970',
"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.": "INSEE death records, via matchID. Date and place of death, nothing before 1970. Search women under their maiden name.",
"Envoie le nom, le prénom et l'année de naissance à matchid.io": 'Sends the last name, first name and birth year to matchid.io',
'Un arbre mondial tenu par ses membres. Profils publics seulement.': 'A worldwide tree kept by its members. Public profiles only.',
"Envoie le nom, le prénom et l'année de naissance à wikitree.com": 'Sends the last name, first name and birth year to wikitree.com',
'Recherche…': 'Searching…',
'Chercher dans {n} sources': 'Search {n} sources',
'Rien trouvé': 'Nothing found',
'1 trouvé': '1 found',
'{n} trouvés': '{n} found',
'La source ne répond pas pour le moment. Réessayez plus tard.': 'This source is not answering right now. Try again later.',
"Personne sous ce nom. Essayez sans l'année, ou avec le nom de naissance.": 'Nobody under this name. Try without the year, or with the maiden name.',
"Ce serveur a épuisé ses recherches sans clé. Son administrateur peut ajouter une clé matchID gratuite (MATCHID_TOKEN).": 'This server has used up its searches without a key. Its administrator can add a free matchID key (MATCHID_TOKEN).',
'Correspond bien': 'Good match',
'À comparer': 'Worth comparing',
'Voir la source': 'See the source',
"Ajouter à l'arbre": 'Add to the tree',
'Ajouté à {name}, à vérifier': 'Added to {name}, needs checking',
'À quelle personne de l’arbre ?': 'Which person in the tree?',
'Pas la même année de naissance que ce résultat': 'Not the same birth year as this result',
"Une année en couleur n'est pas celle de ce résultat.": 'A year in colour is not the one in this result.',
'Rien de nouveau pour {name} : ses dates et ses lieux sont déjà remplis.': 'Nothing new for {name}: their dates and places are already filled in.',
"L'ajout n'a pas marché. Réessayez.": "Adding didn't work. Try again.",
'Seuls les champs vides sont remplis. La personne passe « à vérifier », et la source va dans sa note.': 'Only empty fields are filled. The person is marked "needs checking", and the source goes into their note.',
'Chercher aussi sur': 'Also search on',
"Ces sites s'ouvrent dans votre navigateur, avec le nom déjà tapé quand ils le permettent. Rien ne passe par Parenthèse.": 'These sites open in your browser, with the name already typed in when they allow it. Nothing goes through Parenthèse.',
'France': 'France',
'Belgique, Pays-Bas': 'Belgium, Netherlands',
'Ailleurs': 'Elsewhere',
'Presse ancienne': 'Old newspapers',
'Gallica, journaux numérisés': 'Gallica, digitised newspapers',
'Registres matricules, état civil': 'Military registers, civil records',
'FranceArchives, hommes nés de 1847 à 1901': 'FranceArchives, men born 1847 to 1901',
'Morts pour la France': 'Died for France',
'Mémoire des hommes': 'Mémoire des hommes',
"Légion d'honneur": 'Legion of Honour',
'Base Léonore': 'Léonore database',
'Arbres et relevés': 'Trees and transcriptions',
'État civil et registres': 'Civil and parish records',
'Arbre mondial tenu par ses membres': 'Worldwide tree kept by its members',
'Angleterre et pays de Galles': 'England and Wales',
'Registres du monde entier, compte gratuit': 'Records from around the world, free account',
}
+22
View File
@@ -0,0 +1,22 @@
// Ce qu'un résultat de recherche ajoute à une personne de l'arbre : seulement les champs encore vides,
// jamais une valeur déjà saisie par la famille. La personne passe « à vérifier » et sa note garde la
// source, pour qu'on puisse relire d'où vient l'information. Rien de nouveau : null.
export function changesFromResult(person, result, sourceLabel) {
if (!person || !result) return null
const changes = {}
const hasBirth = Boolean(person.birthYear || person.birthDate)
const hasDeath = Boolean(person.deathYear || person.deathDate)
if (!hasBirth && result.birth?.date) changes.birthYear = result.birth.date
if (!person.birthPlace && result.birth?.place) changes.birthPlace = result.birth.place
if (!hasDeath && result.death?.date) changes.deathYear = result.death.date
if (!person.deathPlace && result.death?.place) changes.deathPlace = result.death.place
if (Object.keys(changes).length === 0) return null
changes.toVerify = true
const sourceLine = `${sourceLabel} : ${result.url}`
const note = String(person.note || '').trim()
changes.note = note.includes(result.url) ? note : [note, sourceLine].filter(Boolean).join('\n')
return changes
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { changesFromResult } from './searchResultChanges.js'
const RESULT = {
id: 'abc123',
birth: { date: '1898-03-14', place: 'Tulle (19)' },
death: { date: '1987-11-08', place: 'Limoges (87)' },
url: 'https://deces.matchid.io/id/abc123',
}
describe('changesFromResult', () => {
it('remplit les champs vides, marque à vérifier et note la source', () => {
const person = { firstName: 'Jean', lastName: 'Dupont', birthYear: 1898, birthPlace: 'Tulle', note: 'Menuisier' }
expect(changesFromResult(person, RESULT, 'Décès en France depuis 1970')).toEqual({
deathYear: '1987-11-08',
deathPlace: 'Limoges (87)',
toVerify: true,
note: 'Menuisier\nDécès en France depuis 1970 : https://deces.matchid.io/id/abc123',
})
})
it("ne touche jamais une valeur déjà saisie, et ne rend rien s'il n'y a rien de nouveau", () => {
const complete = { birthDate: '1898-01-01', birthPlace: 'Tulle', deathDate: '1987-01-01', deathPlace: 'Limoges' }
expect(changesFromResult(complete, RESULT, 'Décès')).toBeNull()
})
it("n'ajoute pas deux fois la même source à la note", () => {
const person = { note: 'Décès : https://deces.matchid.io/id/abc123' }
expect(changesFromResult(person, RESULT, 'Décès').note).toBe('Décès : https://deces.matchid.io/id/abc123')
})
})