Plusieurs prénoms dans le champ Prénom : « Paul, Marie, Jean » crée trois enfants (#203)

Pour un enfant ou un frère, une virgule sépare les prénoms : une personne par prénom, mêmes liens,
nom et dates pour chacune (vides et doublons ignorés, 20 au plus). Parent et conjoint gardent un seul
prénom. Vaut pour le propriétaire, la famille (brouillons) et la démo ; la bulle dit « Paul, Marie et
Jean … sont sur l'arbre ». Sur téléphone, aucune fiche ne s'ouvre et la caméra cadre toute la fratrie
(focusPersons dans Galaxy, qui attend la remise en place de l'arbre). person_added porte batch.

Le corps de l'ajout passe dans addOne : relire avec git diff -w.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-11-01 19:20:59 +01:00
1 parent e596887a44
commit 1565552141
5 files changed
+269 -162

No files matched your search

+192 -162
View File
@@ -67,6 +67,7 @@ import { useEditMode } from './hooks/useEditMode'
import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { joinFirstNames, splitFirstNames } from './utils/firstNames.js'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, replaceGraph, unions } from './data/mockData'
import { readGedcomFile } from './utils/gedcomText'
import { readGedcomBundle, zipErrorMessage } from './utils/gedcomBundle.js'
@@ -1242,6 +1243,9 @@ function App() {
siblingParentLinks.push(...links)
}
}
// « Paul, Marie, Jean » pour un enfant ou un frère : une personne par prénom, mêmes liens pour chacune
const firstNames = splitFirstNames(formData.firstName, formData.relations || [])
const batch = firstNames.length > 1 ? { batch: firstNames.length } : {}
// Un enfant ajouté à une personne qui n'a qu'un conjoint est l'enfant du couple : il passe par les liens
// du couple, comme un frère ou une sœur. Une adoption reste au seul parent choisi.
const relationCount = (formData.relations || []).length
@@ -1256,191 +1260,217 @@ function App() {
}),
}
if (!tree.canEditCurrentTree) {
if (isDemoMode) {
const personId = makeLocalId('local-person')
const birthYear = extractYearFromIsoDate(formData.birthDate)
const deathYear = extractYearFromIsoDate(formData.deathDate)
const newPerson = {
id: personId,
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
deathYear,
isAlive: !deathYear,
photo: null,
frameType: 'round',
}
const addOne = async (formData) => {
if (!tree.canEditCurrentTree) {
if (isDemoMode) {
const personId = makeLocalId('local-person')
const birthYear = extractYearFromIsoDate(formData.birthDate)
const deathYear = extractYearFromIsoDate(formData.deathDate)
const newPerson = {
id: personId,
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
deathYear,
isAlive: !deathYear,
photo: null,
frameType: 'round',
}
replaceGraph({ persons: [...persons, newPerson] })
replaceGraph({ persons: [...persons, newPerson] })
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
const existsUnion = unions.some((union) => (
(String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId))
|| (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: rel.personId,
partner2Id: personId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
const existsUnion = unions.some((union) => (
(String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId))
|| (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: rel.personId,
partner2Id: personId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
})
}
} else if (rel.type === 'parent') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
}
} else if (rel.type === 'parent') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
}
for (const link of siblingParentLinks) {
const alreadyLinked = filiations.some((filiation) => (
String(filiation.childId) === String(personId)
&& (link.unionId
? String(filiation.unionId) === link.unionId
: String(filiation.parentId) === link.parentId)
))
if (alreadyLinked) continue
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
parentId: link.unionId ? null : link.parentId,
unionId: link.unionId,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
}
tree.bumpGraphRevision()
tree.setSelectedPerson({ ...newPerson })
}
for (const link of siblingParentLinks) {
const alreadyLinked = filiations.some((filiation) => (
String(filiation.childId) === String(personId)
&& (link.unionId
? String(filiation.unionId) === link.unionId
: String(filiation.parentId) === link.parentId)
))
if (alreadyLinked) continue
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: link.unionId ? null : link.parentId,
unionId: link.unionId,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
}
tree.bumpGraphRevision()
tree.setSelectedPerson({ ...newPerson })
const draft = editMode.restoreDraft()
const ref = `tmp:${makeLocalId('personne')}`
const addedPersons = [
...(draft.addedPersons || []),
{
ref,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
birthPlace: formData.birthPlace || null,
deathPlace: formData.deathPlace || null,
toVerify: Boolean(formData.toVerify),
notes: formData.notes || null,
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
editMode.updateDraft({ addedPersons })
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'family', relations: relationCount, tree_id: opaqueTreeId, ...batch })
// La personne apparaît en fantôme « Brouillon » : la famille peut en ajouter d'autres, puis envoyer une
// fois avec le bouton « Envoyer » de la barre (Patch, 25/10). Avant, la fenêtre d'envoi s'ouvrait d'office,
// seule façon de montrer qu'un ajout invisible comptait.
setAddPersonPanelVisible(false)
setDraftNotice(t("{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
return { proposed: true, id: isDemoMode ? null : `ghost:brouillon:${ref}`, demoPerson: isDemoMode ? persons[persons.length - 1] : null }
}
const draft = editMode.restoreDraft()
const addedPersons = [
...(draft.addedPersons || []),
{
ref: `tmp:${makeLocalId('personne')}`,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
birthPlace: formData.birthPlace || null,
deathPlace: formData.deathPlace || null,
toVerify: Boolean(formData.toVerify),
notes: formData.notes || null,
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
editMode.updateDraft({ addedPersons })
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'family', relations: relationCount, tree_id: opaqueTreeId })
// La personne apparaît en fantôme « Brouillon » : la famille peut en ajouter d'autres, puis envoyer une
// fois avec le bouton « Envoyer » de la barre (Patch, 25/10). Avant, la fenêtre d'envoi s'ouvrait d'office,
// seule façon de montrer qu'un ajout invisible comptait.
setAddPersonPanelVisible(false)
setDraftNotice(t("{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
return { proposed: true }
}
if (!auth.userAuth.token) return
if (!auth.userAuth.token) return
const newPerson = await createPerson(
tree.treeContext.treeId,
{
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
sex: formData.sex || null,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
// Personne venue d'une source publique (panneau « Chercher quelqu'un ») : lieux, « à vérifier » et source
...(formData.birthPlace ? { birthPlace: formData.birthPlace } : {}),
...(formData.deathPlace ? { deathPlace: formData.deathPlace } : {}),
...(formData.toVerify ? { toVerify: true } : {}),
...(formData.notes ? { notes: formData.notes } : {}),
},
auth.userAuth.token
)
// Créer les liens de relation
const linkedParentIds = new Set()
for (const rel of formData.relations) {
if (rel.type === 'child') {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 },
auth.userAuth.token
)
}
}
for (const link of siblingParentLinks) {
if (linkedParentIds.has(link.parentId)) continue
linkedParentIds.add(link.parentId)
await createParentChildLink(
const newPerson = await createPerson(
tree.treeContext.treeId,
{
parentPersonId: link.parentId,
childPersonId: newPerson.id,
viaUnionId: link.unionId,
parentageType: 'biologique',
displayOrder: 1,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
sex: formData.sex || null,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
// Personne venue d'une source publique (panneau « Chercher quelqu'un ») : lieux, « à vérifier » et source
...(formData.birthPlace ? { birthPlace: formData.birthPlace } : {}),
...(formData.deathPlace ? { deathPlace: formData.deathPlace } : {}),
...(formData.toVerify ? { toVerify: true } : {}),
...(formData.notes ? { notes: formData.notes } : {}),
},
auth.userAuth.token
)
// Créer les liens de relation
const linkedParentIds = new Set()
for (const rel of formData.relations) {
if (rel.type === 'child') {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 },
auth.userAuth.token
)
}
}
for (const link of siblingParentLinks) {
if (linkedParentIds.has(link.parentId)) continue
linkedParentIds.add(link.parentId)
await createParentChildLink(
tree.treeContext.treeId,
{
parentPersonId: link.parentId,
childPersonId: newPerson.id,
viaUnionId: link.unionId,
parentageType: 'biologique',
displayOrder: 1,
},
auth.userAuth.token
)
}
// Mettre à jour le draft avec la nouvelle personne
editMode.updateDraft({
addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id],
})
await tree.refreshCurrentTreeGraph()
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'manager', relations: relationCount, tree_id: opaqueTreeId, ...batch })
return newPerson
}
// Mettre à jour le draft avec la nouvelle personne
editMode.updateDraft({
addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id],
})
if (firstNames.length < 2) return await addOne(formData)
await tree.refreshCurrentTreeGraph()
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'manager', relations: relationCount, tree_id: opaqueTreeId })
return newPerson
const added = []
for (const firstName of firstNames) {
const result = await addOne({ ...formData, firstName })
if (!result) break
added.push(result)
}
if (!added.length) return null
const addedIds = added.map((result) => result.demoPerson?.id ?? result.id).filter((id) => id != null)
if (added[0].proposed) {
const names = joinFirstNames(firstNames.slice(0, added.length), t('et'))
setDraftNotice(t("{names} sont sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { names: [names, formData.lastName].filter(Boolean).join(' ') }))
}
// Sur téléphone, une fiche ouverte couvrirait la fratrie : pas de fiche, la caméra les cadre tous
if (window.matchMedia?.('(max-width: 768px)').matches) {
tree.setSelectedPerson(null)
if (addedIds.length > 1) requestAnimationFrame(() => galaxyZoomApiRef.current?.focusPersons?.(addedIds))
} else if (added[0].demoPerson) {
tree.setSelectedPerson({ ...added[0].demoPerson })
}
return added[0]
} catch (err) {
setAddPersonError(err.message || t("Erreur lors de l'ajout de la personne"))
} finally {
+26
View File
@@ -96,6 +96,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [])
useEffect(() => cancelTabsRequest, [cancelTabsRequest])
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
const pendingFrameRef = useRef(null) // personnes à cadrer une fois l'arbre reposé (ajout de plusieurs prénoms)
// Lien d'un onglet (?famille=<charnière>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
// la personne de départ arrive après (visiteur), tant qu'on n'a pas soi-même changé d'onglet
const linkFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
@@ -516,6 +517,26 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const screenY = phone ? rect.height * 0.22 : rect.height / 2
targetTransformRef.current = { scale, x: screenX - pos.cx * scale, y: screenY - pos.cy * scale }
},
// Plusieurs personnes venant d'être ajoutées : toutes dans le champ, au-dessus de la barre du bas. Si l'arbre
// n'est pas encore reposé avec elles, le cadrage attend la prochaine mise en place.
focusPersons: (personIds) => {
const canvas = canvasRef.current
const wanted = new Set(personIds.map((id) => `p-${id}`))
const nodes = (layoutDataRef.current?.children || []).filter((n) => wanted.has(n.id))
if (!canvas || nodes.length < personIds.length) { pendingFrameRef.current = personIds; return }
pendingFrameRef.current = null
const rect = canvas.getBoundingClientRect()
const now = performance.now()
const centers = nodes.map((n) => getAnimatedPos(n, now))
const xs = centers.map((p) => p.cx)
const ys = centers.map((p) => p.cy)
const margin = 140 // un portrait et son prénom
const fit = Math.min((rect.width - 32) / (Math.max(...xs) - Math.min(...xs) + margin), (rect.height * 0.6) / (Math.max(...ys) - Math.min(...ys) + margin))
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(Math.max(targetTransformRef.current.scale, 1), fit)))
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
const cy = (Math.min(...ys) + Math.max(...ys)) / 2
targetTransformRef.current = { scale, x: rect.width / 2 - cx * scale, y: rect.height * 0.42 - cy * scale }
},
getScale: () => transformRef.current.scale,
moveSibling,
getSiblingPlace,
@@ -849,6 +870,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFocusRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
}
if (pendingFrameRef.current) {
const ids = pendingFrameRef.current
pendingFrameRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
}
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
+3
View File
@@ -44,6 +44,9 @@ export default {
'Votre famille': 'Your family',
'Faire le mien': 'Make my own',
'un arbre pour votre famille, gratuit': 'a tree for your family, free',
// Plusieurs prénoms dans le champ Prénom
'et': 'and',
"{names} sont sur l'arbre, en brouillon. Envoyez quand vous avez fini.": '{names} are on the tree, as a draft. Send when you are done.',
'Lien ajouté à la contribution': 'Link added to your contribution',
'Lien familial ajouté': 'Family link added',
'Lien modifié (démo locale)': 'Link changed (local demo)',
+21
View File
@@ -0,0 +1,21 @@
// « Paul, Marie, Jean » dans le champ Prénom d'un enfant ou d'un frère : une personne par prénom.
// Sans virgule, ou pour un parent ou un conjoint, le champ reste un seul prénom.
const MAX_NAMES = 20
export function splitFirstNames(value, relations = []) {
const text = String(value || '')
if (!text.includes(',')) return [text]
if (!relations.some((rel) => rel.type === 'child' || rel.type === 'sibling')) return [text]
const names = []
for (const part of text.split(',')) {
const name = part.trim()
if (name && !names.includes(name)) names.push(name)
}
return names.length > 1 ? names.slice(0, MAX_NAMES) : [names[0] || text]
}
// « Paul, Marie et Jean »
export function joinFirstNames(names, and) {
if (names.length < 2) return names[0] || ''
return `${names.slice(0, -1).join(', ')} ${and} ${names[names.length - 1]}`
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { joinFirstNames, splitFirstNames } from './firstNames.js'
const child = [{ type: 'child', personId: 1 }]
describe('plusieurs prénoms dans le champ Prénom', () => {
it("découpe pour un enfant ou un frère", () => {
expect(splitFirstNames('Paul, Marie, Jean', child)).toEqual(['Paul', 'Marie', 'Jean'])
expect(splitFirstNames('Paul,Marie', [{ type: 'sibling', personId: 2 }])).toEqual(['Paul', 'Marie'])
})
it('laisse un seul prénom sans virgule, ou pour un parent ou un conjoint', () => {
expect(splitFirstNames('Jean-Pierre', child)).toEqual(['Jean-Pierre'])
expect(splitFirstNames('Paul, Marie', [{ type: 'parent', personId: 1 }])).toEqual(['Paul, Marie'])
expect(splitFirstNames('Paul, Marie', [])).toEqual(['Paul, Marie'])
})
it('ignore les vides et les doublons', () => {
expect(splitFirstNames(' Paul , , Marie, Paul ', child)).toEqual(['Paul', 'Marie'])
expect(splitFirstNames('Paul,', child)).toEqual(['Paul'])
})
it('les relie pour le message', () => {
expect(joinFirstNames(['Paul', 'Marie', 'Jean'], 'et')).toBe('Paul, Marie et Jean')
expect(joinFirstNames(['Paul'], 'et')).toBe('Paul')
})
})