Merge pull request #68 from IliesAllali/gedcom-souvenirs

Import GEDCOM : les fichiers cités deviennent des souvenirs (#59)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-16 20:13:39 +02:00
commit 0e96ffc020
8 files changed
+619 -38

No files matched your search

+180 -3
View File
@@ -1,6 +1,7 @@
// Photos citées par un fichier GEDCOM. Le fichier ne contient pas les images, seulement leur chemin sur
// l'ordinateur de la personne (« C:\Généalogie\Photos\jean.jpg »). L'import renvoie ce chemin pour chaque
// personne, et l'app les relie aux fichiers du dossier que la personne choisit, sans rien garder en base.
// Photos et documents cités par un fichier GEDCOM. Le fichier ne contient pas les fichiers, seulement leur
// chemin sur l'ordinateur de la personne (« C:\Généalogie\Photos\jean.jpg »). L'import renvoie ces chemins
// pour chaque personne, et l'app les relie aux fichiers du dossier que la personne choisit, sans rien
// garder en base : le portrait devient la photo de la fiche, le reste des souvenirs.
export type GedcomPhotoNode = {
xref?: string
@@ -30,6 +31,13 @@ export function photoPathForPerson(
indi: GedcomPhotoNode,
objeByXref: Map<string, GedcomPhotoNode>,
): string | null {
// Family Tree Maker désigne la photo principale par « 1 _PHOTO @O1@ » sous la personne
const photoPointer = child(indi, '_PHOTO')?.value.match(/^@([^@]+)@$/)?.[1]
const flagged = photoPointer ? objeByXref.get(photoPointer) : undefined
for (const file of flagged?.children.filter((c) => c.tag === 'FILE') ?? []) {
const filePath = file.value.trim()
if (isImagePath(filePath) && filePath.length <= MAX_PATH_LENGTH) return filePath
}
let first: string | null = null
for (const ref of indi.children) {
if (ref.tag !== 'OBJE') continue
@@ -54,3 +62,172 @@ export function indexObjeRecords(roots: GedcomPhotoNode[]): Map<string, GedcomPh
}
return map
}
// ---------- Souvenirs : tous les fichiers cités pour une personne ----------
// Un logiciel de généalogie accroche ses fichiers à plusieurs endroits : sous la personne (« 1 OBJE »), sous
// un événement (« 1 BIRT » / « 2 OBJE »), sous une source citée (« 2 SOUR @S1@ » / « 3 OBJE »), dans la
// source elle-même (« 0 @S1@ SOUR » / « 1 OBJE ») ou sous la famille (actes de mariage). On les ramasse
// tous, comme Gramps (libgedcom.py), pour que les actes, registres et photos de famille deviennent des
// souvenirs. Le portrait reste à part (photoPathForPerson).
export type GedcomMediaRef = { file: string; caption: string | null; source: string | null }
const MEDIA_EXTENSIONS = new Set([
...IMAGE_EXTENSIONS,
'pdf', 'txt',
'mp4', 'mov', 'm4v', 'webm', 'avi',
'mp3', 'm4a', 'wav', 'ogg', 'aac', 'flac',
])
const MAX_MEDIA_PER_PERSON = 60
const MAX_CAPTION_LENGTH = 280
const MAX_SOURCE_LENGTH = 500
export type GedcomRecords = {
obje: Map<string, GedcomPhotoNode>
sour: Map<string, GedcomPhotoNode>
note: Map<string, GedcomPhotoNode>
fam: Map<string, GedcomPhotoNode>
}
export function indexGedcomRecords(roots: GedcomPhotoNode[]): GedcomRecords {
const records: GedcomRecords = { obje: new Map(), sour: new Map(), note: new Map(), fam: new Map() }
for (const node of roots) {
if (!node.xref) continue
if (node.tag === 'OBJE') records.obje.set(node.xref, node)
else if (node.tag === 'SOUR') records.sour.set(node.xref, node)
else if (node.tag === 'NOTE') records.note.set(node.xref, node)
else if (node.tag === 'FAM') records.fam.set(node.xref, node)
}
return records
}
function pointerOf(value: string): string | null {
return value.match(/^@([^@]+)@$/)?.[1] ?? null
}
// Chemin local d'un fichier cité : les adresses web ne sont pas des fichiers de l'ordinateur ;
// « file:///C:/… » (GEDCOM 7) redevient un chemin ordinaire.
export function localMediaPath(value: string): string | null {
let path = value.trim()
if (!path || path.length > MAX_PATH_LENGTH) return null
if (/^file:\/\//i.test(path)) {
path = path.replace(/^file:\/\/(localhost)?/i, '')
if (/^\/[a-z]:[\\/]/i.test(path)) path = path.slice(1)
try { path = decodeURIComponent(path) } catch { /* chemin laissé tel quel */ }
} else if (/^[a-z][a-z0-9+.-]+:/i.test(path)) {
return null
}
const name = path.split(/[\\/]/).pop() || ''
const dot = name.lastIndexOf('.')
return dot > 0 && MEDIA_EXTENSIONS.has(name.slice(dot + 1).toLowerCase()) ? path : null
}
function joinedText(node: GedcomPhotoNode | undefined): string {
if (!node) return ''
let text = node.value
for (const c of node.children) {
if (c.tag === 'CONT') text += `\n${c.value}`
else if (c.tag === 'CONC') text += c.value
}
return text
}
// Heredis Mac écrit certaines notes en RTF : on n'en garde que le texte
export function plainNoteText(raw: string): string {
let text = raw
if (/^\s*\{\\rtf/.test(text)) {
text = text
.replace(/\{\\\*[^{}]*\}/g, '')
.replace(/\{\\(fonttbl|colortbl|stylesheet|info)[^{}]*(\{[^{}]*\}[^{}]*)*\}/g, '')
.replace(/\\'([0-9a-f]{2})/gi, (_, hex) => new TextDecoder('windows-1252').decode(new Uint8Array([parseInt(hex, 16)])))
.replace(/\\par[d]?\b ?/g, '\n')
.replace(/\\[a-z]+-?\d* ?/gi, '')
.replace(/[{}]/g, '')
}
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
}
function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string {
if (!node) return ''
const pointer = pointerOf(node.value)
return plainNoteText(joinedText(pointer ? records.note.get(pointer) : node))
}
function clipText(value: string, max: number): string | null {
const text = value.replace(/\s+/g, ' ').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
function baseNameWithoutExtension(path: string): string {
const name = path.split(/[\\/]/).pop() || ''
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(0, dot) : name
}
// Titre d'une source citée : son titre (ou son abréviation), puis la page ou le détail de la citation
function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null {
const pointer = pointerOf(citation.value)
const record = pointer ? records.sour.get(pointer) : null
const title = joinedText(child(record ?? undefined, 'TITL')) || joinedText(child(record ?? undefined, 'ABBR')) || (pointer ? '' : citation.value)
const page = joinedText(child(citation, 'PAGE'))
return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH)
}
export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords, portraitPath: string | null = null): GedcomMediaRef[] {
const refs: GedcomMediaRef[] = []
const seenPaths = new Set<string>()
if (portraitPath) seenPaths.add(portraitPath.toLowerCase())
const visitedRecords = new Set<GedcomPhotoNode>()
const addObje = (ref: GedcomPhotoNode, source: string | null) => {
const pointer = pointerOf(ref.value)
const obje = pointer ? records.obje.get(pointer) : ref
if (!obje) return
const objeTitle = joinedText(child(obje, 'TITL')) || joinedText(child(ref, 'TITL'))
const objeNote = noteText(child(obje, 'NOTE'), records) || noteText(child(ref, 'NOTE'), records)
const form = (child(obje, 'FORM')?.value || '').toLowerCase()
if (form === 'url') return
for (const file of obje.children.filter((c) => c.tag === 'FILE')) {
if ((child(file, 'FORM')?.value || '').toLowerCase() === 'url') continue
const path = localMediaPath(file.value)
if (!path || seenPaths.has(path.toLowerCase()) || refs.length >= MAX_MEDIA_PER_PERSON) continue
seenPaths.add(path.toLowerCase())
const title = joinedText(child(file, 'TITL')) || objeTitle || objeNote
refs.push({
file: path,
caption: clipText(title || baseNameWithoutExtension(path), MAX_CAPTION_LENGTH),
source,
})
}
}
const walk = (node: GedcomPhotoNode, source: string | null) => {
for (const c of node.children) {
if (c.tag === 'OBJE') {
addObje(c, source)
} else if (c.tag === 'SOUR') {
const label = sourceLabel(c, records) ?? source
walk(c, label)
const pointer = pointerOf(c.value)
const record = pointer ? records.sour.get(pointer) : null
if (record && !visitedRecords.has(record)) {
visitedRecords.add(record)
walk(record, label)
}
} else if (c.tag === 'FAMS') {
const pointer = pointerOf(c.value)
const fam = pointer ? records.fam.get(pointer) : null
if (fam && !visitedRecords.has(fam)) {
visitedRecords.add(fam)
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source)
}
} else if (!pointerOf(c.value) && c.children.length > 0) {
walk(c, source)
}
}
}
walk(indi, null)
return refs
}
+16 -5
View File
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import { indexObjeRecords, photoPathForPerson } from '../lib/gedcom-photos.js'
import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson } from '../lib/gedcom-photos.js'
import type { MembershipRole } from '../types/auth.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
@@ -23,6 +23,8 @@ const MAX_IMPORTED_FAMILIES = 20000
// redémarrage pm2 qui suivait effaçait les limites d'essais gardées en mémoire. Au-delà, le fichier est refusé.
const MAX_GEDCOM_NODES = 300_000
const MAX_IMPORTED_LINKS = 60_000
// Fichiers cités renvoyés à l'app pour en faire des souvenirs : au-delà, la réponse deviendrait trop lourde
const MAX_IMPORTED_MEDIA_REFS = 20_000
class GedcomTooLargeError extends Error {}
@@ -480,8 +482,12 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
}
const xrefToPersonId = new Map<string, string>()
const objeByXref = indexObjeRecords(roots)
const records = indexGedcomRecords(roots)
const objeByXref = records.obje
const photoRefs: Array<{ personId: string; file: string }> = []
// Les autres fichiers cités (actes, registres, photos de famille) : des souvenirs pour les personnes
// créées par cet import. Une personne déjà dans l'arbre n'en reçoit pas, un second import ne double rien.
const mediaRefs: Array<{ personId: string; file: string; caption: string | null; source: string | null }> = []
// Ce que l'arbre contient déjà : un second import du même fichier complète l'arbre sans rien doubler.
// Chaque personne existante ne sert qu'une fois, deux fiches identiques du fichier restent deux personnes.
@@ -569,6 +575,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
xrefToPersonId.set(indi.xref!, person.id)
personsCreated++
if (photoPath) photoRefs.push({ personId: person.id, file: photoPath })
for (const ref of mediaRefsForPerson(indi, records, photoPath)) {
if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break
mediaRefs.push({ personId: person.id, ...ref })
}
}
for (const fam of famNodes) {
@@ -639,7 +649,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
action: 'gedcom_imported',
entityType: 'tree',
entityId: treeId,
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, photoRefs: photoRefs.length },
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
},
})
}, { timeout: 60000 })
@@ -647,7 +657,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
importInProgress = false
}
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne (rien n'est gardé)
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, photoRefs })
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les
// autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base)
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, photoRefs, mediaRefs })
})
}
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest'
import { indexGedcomRecords, localMediaPath, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
// Pour chaque personne : son portrait et ses souvenirs, tels que l'import les renvoie
function mediaOf(text: string) {
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const out: Record<string, { portrait: string | null; media: ReturnType<typeof mediaRefsForPerson> }> = {}
for (const indi of roots.filter((n) => n.tag === 'INDI' && n.xref)) {
const portrait = photoPathForPerson(indi, records.obje)
out[indi.xref!] = { portrait, media: mediaRefsForPerson(indi, records, portrait) }
}
return out
}
describe('souvenirs cités par un GEDCOM', () => {
it('Heredis PC : média sous la source citée d\u2019un événement, titre dans la note', () => {
const text = [
'0 HEAD',
'1 SOUR Heredis PC2023',
'0 @I1@ INDI',
'1 NAME Louis Eugène Lucien /RAGOT/',
'1 BIRT',
'2 DATE 1879',
'2 SOUR @S1@',
'3 PAGE Acte n° 12',
'3 OBJE @O1@',
'0 @S1@ SOUR',
'1 TITL Registre des naissances, Vendée',
'0 @O1@ OBJE',
'1 FILE C:/Users/Moi/Documents/Heredis/Médias/Naissance Ragot Louis Eugène Lucien.jpg',
'2 FORM JPG',
'1 NOTE Naissance Ragot Louis Eugène Lucien',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: null,
media: [{
file: 'C:/Users/Moi/Documents/Heredis/Médias/Naissance Ragot Louis Eugène Lucien.jpg',
caption: 'Naissance Ragot Louis Eugène Lucien',
source: 'Registre des naissances, Vendée, Acte n° 12',
}],
})
})
it('Heredis : média accroché à la fiche de la source elle-même', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Louis /RAGOT/',
'1 EVEN',
'2 TYPE Militaire',
'2 SOUR @S2@',
'0 @S2@ SOUR',
'1 TITL Fiche matriculaire',
'1 OBJE @O2@',
'0 @O2@ OBJE',
'1 FILE D:\\Généalogie\\Ragot Louis fiche matriculaire.pdf',
'2 FORM PDF',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([{
file: 'D:\\Généalogie\\Ragot Louis fiche matriculaire.pdf',
caption: 'Ragot Louis fiche matriculaire',
source: 'Fiche matriculaire',
}])
})
it('Heredis Mac : note de titre en RTF', () => {
expect(plainNoteText([
'{\\rtf1\\ansi\\ansicpg1252\\cocoartf1561\\cocoasubrtf400',
'{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}',
'{\\colortbl;\\red255\\green255\\blue255;}',
'{\\*\\expandedcolortbl;;}',
'\\pard\\tx560\\tx1120\\pardirnatural\\partightenfactor0',
'',
"\\f0\\fs24 \\cf0 acte mariage Th\\'e9ophane V\\'c9NIEN avec Emma JEANDOT}",
].join('\n'))).toBe('acte mariage Théophane VÉNIEN avec Emma JEANDOT')
})
it('Gramps : renvoi sous un événement, titre sous FILE (GEDCOM 5.5.1)', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Marie /Martin/',
'1 OBJE @M1@',
'1 MARR',
'2 OBJE @M2@',
'0 @M1@ OBJE',
'1 FILE photos/marie.jpg',
'2 FORM jpg',
'0 @M2@ OBJE',
'1 FILE photos/mariage 1950.png',
'2 FORM png',
'2 TITL Mariage de Marie et Paul',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'photos/marie.jpg',
media: [{ file: 'photos/mariage 1950.png', caption: 'Mariage de Marie et Paul', source: null }],
})
})
it('RootsMagic : OBJE écrit sous la personne, le portrait _PRIM n\u2019est pas repris en souvenir', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE C:\\RootsMagic\\Media\\jean portrait.jpg',
'2 FORM jpg',
'2 TITL Jean',
'2 _PRIM Y',
'1 OBJE',
'2 FILE C:\\RootsMagic\\Media\\recensement 1906.jpg',
'2 FORM jpg',
'2 TITL Recensement de 1906',
'2 _TYPE PHOTO',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'C:\\RootsMagic\\Media\\jean portrait.jpg',
media: [{ file: 'C:\\RootsMagic\\Media\\recensement 1906.jpg', caption: 'Recensement de 1906', source: null }],
})
})
it('Family Tree Maker : _PHOTO désigne le portrait, les autres deviennent des souvenirs', () => {
const text = [
'0 @I1@ INDI',
'1 _PHOTO @O2@',
'1 OBJE @O1@',
'1 OBJE @O2@',
'0 @O1@ OBJE',
'1 FILE C:\\FTM\\Media\\groupe 1930.jpg',
'2 FORM jpg',
'2 TITL Toute la famille en 1930',
'0 @O2@ OBJE',
'1 FILE C:\\FTM\\Media\\portrait.jpg',
'2 FORM jpg',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'C:\\FTM\\Media\\portrait.jpg',
media: [{ file: 'C:\\FTM\\Media\\groupe 1930.jpg', caption: 'Toute la famille en 1930', source: null }],
})
})
it('Ancestry, MyHeritage : les adresses web ne sont pas des fichiers de l\u2019ordinateur', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE https://mediasvc.ancestry.com/v2/image/namespaces/1093/media/abc.jpg',
'2 FORM jpg',
'1 OBJE',
'2 FORM url',
'2 FILE www.exemple.org/acte',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([])
})
it('GEDCOM 7 : chemin en file://, type MIME et titre sous FILE', () => {
const text = [
'0 HEAD',
'1 GEDC',
'2 VERS 7.0',
'0 @I1@ INDI',
'1 OBJE @O1@',
'0 @O1@ OBJE',
'1 FILE file:///C:/Archives/acte%20de%20d%C3%A9c%C3%A8s.pdf',
'2 FORM application/pdf',
'2 TITL Acte de décès',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([{ file: 'C:/Archives/acte de décès.pdf', caption: 'Acte de décès', source: null }])
})
it('acte de mariage accroché à la famille : souvenir des deux conjoints', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Paul /Durand/',
'1 FAMS @F1@',
'0 @I2@ INDI',
'1 NAME Jeanne /Petit/',
'1 FAMS @F1@',
'0 @I3@ INDI',
'1 NAME Enfant /Durand/',
'1 FAMC @F1@',
'0 @F1@ FAM',
'1 HUSB @I1@',
'1 WIFE @I2@',
'1 CHIL @I3@',
'1 MARR',
'2 DATE 1922',
'2 SOUR Registre des mariages de Lille',
'3 OBJE',
'4 FILE mariages/durand-petit 1922.jpg',
'4 FORM jpg',
].join('\n')
const out = mediaOf(text)
const expected = [{ file: 'mariages/durand-petit 1922.jpg', caption: 'durand-petit 1922', source: 'Registre des mariages de Lille' }]
expect(out.I1.media).toEqual(expected)
expect(out.I2.media).toEqual(expected)
expect(out.I3.media).toEqual([])
})
it('un fichier cité deux fois n\u2019est gardé qu\u2019une fois', () => {
const text = [
'0 @I1@ INDI',
'1 BIRT',
'2 OBJE @O1@',
'1 BAPM',
'2 OBJE @O1@',
'1 OBJE',
'2 FILE C:\\medias\\ACTE.PDF',
'0 @O1@ OBJE',
'1 FILE C:\\medias\\acte.pdf',
].join('\n')
expect(mediaOf(text).I1.media.map((m) => m.file)).toEqual(['C:\\medias\\acte.pdf'])
})
})
describe('localMediaPath', () => {
it('garde les chemins locaux de fichiers connus, écarte le reste', () => {
expect(localMediaPath('C:\\Photos\\jean.JPG')).toBe('C:\\Photos\\jean.JPG')
expect(localMediaPath('/Users/moi/Heredis/acte.pdf')).toBe('/Users/moi/Heredis/acte.pdf')
expect(localMediaPath('file:///home/moi/voix.mp3')).toBe('/home/moi/voix.mp3')
expect(localMediaPath('https://exemple.org/photo.jpg')).toBeNull()
expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull()
expect(localMediaPath('')).toBeNull()
})
})
+62 -6
View File
@@ -45,6 +45,7 @@ import {
updatePerson,
uploadAnnotationPhoto,
reorderSiblings,
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { useAuth } from './hooks/useAuth'
@@ -58,10 +59,10 @@ import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
import { loadPhotoRefs, pickFirstFaces, prepareImageForUpload, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { loadMediaRefs, loadPhotoRefs, pickFirstFaces, prepareImageForUpload, saveMediaRefs, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages'
import { getSiblingParentLinks } from './utils/familyLinks'
import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload'
import { getUploadMimeType, inferMediaTypeFromMimeType, validateMediaFile } from './utils/mediaUpload'
import {
getSource,
identifyUser,
@@ -482,7 +483,7 @@ function App() {
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], faces: [] })
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [] })
const photoStartDirtyRef = useRef(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [showDraftBanner, setShowDraftBanner] = useState(false)
@@ -575,12 +576,13 @@ function App() {
.filter(Boolean),
}), [linkedSelfPersonId, tree.treeContext.rootPersonId])
const openPhotoStart = useCallback(({ personsImported = null, refs = null, treeId = null } = {}) => {
const openPhotoStart = useCallback(({ personsImported = null, refs = null, mediaRefs = null, treeId = null } = {}) => {
photoStartDirtyRef.current = false
setPhotoStart({
visible: true,
personsImported,
refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId),
mediaRefs: mediaRefs ?? loadMediaRefs(treeId || tree.treeContext.treeId),
faces: computeFirstFaces(),
})
}, [computeFirstFaces, tree.treeContext.treeId])
@@ -2394,13 +2396,16 @@ function App() {
if (imported) {
const refs = Array.isArray(imported.photoRefs) ? imported.photoRefs : []
const mediaRefs = Array.isArray(imported.mediaRefs) ? imported.mediaRefs : []
savePhotoRefs(createdTree.id, refs)
saveMediaRefs(createdTree.id, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(imported.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
via: 'wizard',
})
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs })
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs, mediaRefs })
} else {
// L'arbre existe déjà : on l'ouvre vide et on dit où réessayer, plutôt que d'en créer un second
setTreeImportError(importError)
@@ -2561,13 +2566,16 @@ function App() {
const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => {
await refreshTreeAndKeepSelection(null, { relayout: true })
const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : []
const mediaRefs = Array.isArray(result?.mediaRefs) ? result.mediaRefs : []
savePhotoRefs(treeIdOverride || tree.treeContext.treeId, refs)
saveMediaRefs(treeIdOverride || tree.treeContext.treeId, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(result?.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
})
setAdminPanelVisible(false)
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs })
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs, mediaRefs })
}, [refreshTreeAndKeepSelection, openPhotoStart, tree.treeContext.treeId])
// Un portrait envoyé sans recharger l'arbre (rechargé une fois, à la fermeture de la fenêtre).
@@ -2617,6 +2625,51 @@ function App() {
return { placedIds }
}, [sendPortrait])
// Les autres fichiers retrouvés dans le dossier : des souvenirs, avec le titre et la source du logiciel
const handlePhotoStartMedia = useCallback(async (matches, onProgress) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
const added = []
for (let i = 0; i < matches.length; i += 1) {
const { personId, file, caption, source } = matches[i]
try {
if (!accessToken || !tree.treeContext.treeId) break
const prepared = await prepareImageForUpload(file)
const type = inferMediaTypeFromMimeType(prepared.type)
if (!validateMediaFile(prepared, type)) {
const payload = {
type,
fileName: prepared.name || file.name,
mimeType: getUploadMimeType(prepared, type),
sizeBytes: prepared.size,
dataBase64: await fileToBase64(prepared),
caption: caption ? String(caption).slice(0, 280) : null,
source: source ? String(source).slice(0, 500) : null,
}
// Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus
for (let attempt = 0; ; attempt += 1) {
try {
await uploadPersonMedia(tree.treeContext.treeId, personId, payload, accessToken)
added.push(matches[i])
photoStartDirtyRef.current = true
break
} catch (error) {
if (error?.status === 429 && attempt < 2) {
await new Promise((resolve) => window.setTimeout(resolve, 15000))
continue
}
break
}
}
}
} catch {
// Fichier illisible : compté comme non envoyé
}
onProgress?.(i + 1)
}
trackAppEvent('memories_bulk_added', { added: sizeBucket(added.length), failed: sizeBucket(matches.length - added.length) })
return { added }
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handlePhotoStartClose = useCallback(async () => {
setPhotoStart((current) => ({ ...current, visible: false }))
if (photoStartDirtyRef.current) {
@@ -2978,10 +3031,13 @@ function App() {
visible={photoStart.visible}
personsImported={photoStart.personsImported}
photoRefs={photoStart.refs}
mediaRefs={photoStart.mediaRefs}
faces={photoStart.faces}
onUploadOne={handlePhotoStartUploadOne}
onBulkPlace={handlePhotoStartBulk}
onBulkMedia={handlePhotoStartMedia}
onRefsChange={(refs) => savePhotoRefs(tree.treeContext.treeId, refs)}
onMediaRefsChange={(refs) => saveMediaRefs(tree.treeContext.treeId, refs)}
onClose={handlePhotoStartClose}
/>
{/* Annotation Toolbar (visible uniquement en mode édition) */}
+76 -23
View File
@@ -33,35 +33,45 @@ function folderOf(filePath) {
// Après un import GEDCOM (ou depuis les paramètres) : retrouver les photos que le logiciel connaissait,
// et demander les premiers visages un par un.
// Une référence et sa correspondance retrouvée partagent le chemin cité par le logiciel
const refKey = (ref) => `${ref.personId}|${ref.sourcePath ?? ref.file}`
export default function PhotoStartPanel({
visible,
personsImported = null,
photoRefs = [],
mediaRefs = [],
faces = [],
onUploadOne,
onBulkPlace,
onBulkMedia,
onRefsChange,
onMediaRefsChange,
onClose,
}) {
const folderInputRef = useRef(null)
const faceInputRef = useRef(null)
const faceTargetRef = useRef(null)
const [remaining, setRemaining] = useState(photoRefs)
const [remainingMedia, setRemainingMedia] = useState(mediaRefs)
const [found, setFound] = useState(null)
const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, failed: 0 })
const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 })
const [faceState, setFaceState] = useState({})
const [shownFaces, setShownFaces] = useState(faces)
const [placedTotal, setPlacedTotal] = useState(0)
const [memoriesTotal, setMemoriesTotal] = useState(0)
// La liste des visages et les chemins sont figés à l'ouverture : une ligne ne disparaît pas sous le doigt
useEffect(() => {
if (!visible) return
setRemaining(photoRefs)
setRemainingMedia(mediaRefs)
setFound(null)
setBulk({ running: false, done: 0, total: 0, placed: null, failed: 0 })
setBulk({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 })
setFaceState({})
setShownFaces(faces)
setPlacedTotal(0)
setMemoriesTotal(0)
}, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@@ -80,7 +90,7 @@ export default function PhotoStartPanel({
return undefined
}, [visible])
const exampleFolder = useMemo(() => folderOf(remaining[0]?.file), [remaining])
const exampleFolder = useMemo(() => folderOf(remaining[0]?.file || remainingMedia[0]?.file), [remaining, remainingMedia])
if (!visible) return null
@@ -88,15 +98,28 @@ export default function PhotoStartPanel({
const files = Array.from(event.target.files || [])
event.target.value = ''
if (files.length === 0) return
setBulk((b) => ({ ...b, placed: null, failed: 0 }))
setFound(matchPhotoFiles(remaining, files))
setBulk((b) => ({ ...b, placed: null, added: 0, failed: 0 }))
const portraits = matchPhotoFiles(remaining, files)
const media = matchPhotoFiles(remainingMedia, files)
setFound({ matches: portraits.matches, media: media.matches })
}
const handlePlace = async () => {
if (!found?.matches.length || bulk.running) return
const total = found.matches.length
setBulk({ running: true, done: 0, total, placed: null, failed: 0 })
const result = await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done })))
const mediaMatches = found?.media || []
if ((!found?.matches.length && !mediaMatches.length) || bulk.running) return
const portraitCount = found.matches.length
const total = portraitCount + mediaMatches.length
setBulk({ running: true, done: 0, total, placed: null, added: 0, failed: 0 })
const result = portraitCount
? await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done })))
: { placedIds: [] }
const mediaResult = mediaMatches.length && onBulkMedia
? await onBulkMedia(mediaMatches, (done) => setBulk((b) => ({ ...b, done: portraitCount + done })))
: { added: [] }
const addedKeys = new Set(mediaResult.added.map(refKey))
const nextRemainingMedia = remainingMedia.filter((ref) => !addedKeys.has(refKey(ref)))
setRemainingMedia(nextRemainingMedia)
onMediaRefsChange?.(nextRemainingMedia)
const placedIds = new Set(result.placedIds)
// Une personne de la liste des visages dont la photo vient d'être placée passe en « Ajoutée »
const shownIds = new Set(shownFaces.map((p) => String(p.id)))
@@ -113,7 +136,8 @@ export default function PhotoStartPanel({
onRefsChange?.(nextRemaining)
setFound(null)
setPlacedTotal((n) => n + placedIds.size)
setBulk({ running: false, done: total, total, placed: placedIds.size, failed: total - placedIds.size })
setMemoriesTotal((n) => n + addedKeys.size)
setBulk({ running: false, done: total, total, placed: placedIds.size, added: addedKeys.size, failed: total - placedIds.size - addedKeys.size })
}
const pickFace = (personId) => {
@@ -137,7 +161,29 @@ export default function PhotoStartPanel({
})
}
const refsKnown = photoRefs.length > 0
const refsKnown = photoRefs.length > 0 || mediaRefs.length > 0
const somethingLeft = remaining.length > 0 || remainingMedia.length > 0
const foundMedia = found?.media || []
const foundTotal = (found?.matches.length || 0) + foundMedia.length
const intro = () => {
if (remaining.length > 0 && remainingMedia.length > 0) {
return remaining.length === 1
? t('Votre logiciel associait une photo à 1 personne, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place le portrait et ajoute le reste aux souvenirs.', { m: remainingMedia.length })
: t('Votre logiciel associait une photo à {n} personnes, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place les portraits et ajoute le reste aux souvenirs.', { n: remaining.length, m: remainingMedia.length })
}
if (remainingMedia.length > 0) {
return plural(remainingMedia.length, 'Votre logiciel citait 1 fichier, un acte ou une photo de famille. Choisissez le dossier où il le range, Parenthèse l’ajoute aux souvenirs.', 'Votre logiciel citait {n} fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse les ajoute aux souvenirs de chaque personne.')
}
return plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')
}
const foundSummary = () => {
if (foundMedia.length === 0) return plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })
if (found.matches.length === 0) return plural(foundMedia.length, '1 fichier retrouvé sur {k}.', '{n} fichiers retrouvés sur {k}.', { k: remainingMedia.length })
return t('{portraits} et {files} retrouvés.', {
portraits: plural(found.matches.length, '1 portrait', '{n} portraits'),
files: plural(foundMedia.length, '1 autre fichier', '{n} autres fichiers'),
})
}
const addedCount = Object.values(faceState).filter((s) => s?.preview && !s.fromFolder).length + placedTotal
return (
@@ -159,11 +205,9 @@ export default function PhotoStartPanel({
{refsKnown && (
<section className="psp-block psp-block--folder" aria-labelledby="pspFolderTitle">
<h3 id="pspFolderTitle" className="psp-h">{t('Vos photos sont déjà sur votre ordinateur')}</h3>
{remaining.length > 0 ? (
{somethingLeft ? (
<>
<p className="psp-p">
{plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')}
</p>
<p className="psp-p">{intro()}</p>
{exampleFolder && (
<p className="pz-hint">{t('Il les rangeait dans {folder}', { folder: exampleFolder })}</p>
)}
@@ -190,12 +234,14 @@ export default function PhotoStartPanel({
</div>
) : found ? (
<div className="psp-found">
{found.matches.length > 0 ? (
{foundTotal > 0 ? (
<>
<p className="psp-p"><strong>{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}</strong></p>
<p className="psp-p"><strong>{foundSummary()}</strong></p>
<div className="psp-row-actions">
<button type="button" className="pz-btn pz-btn--accent" onClick={handlePlace}>
{plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
{foundMedia.length > 0
? plural(foundTotal, 'Ajouter ce fichier', 'Tout ajouter ({n})')
: plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
</button>
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => folderInputRef.current?.click()}>
{t('Un autre dossier')}
@@ -212,7 +258,7 @@ export default function PhotoStartPanel({
</>
)}
</div>
) : remaining.length > 0 ? (
) : somethingLeft ? (
<button type="button" className="pz-btn pz-btn--accent psp-folder-btn" onClick={() => folderInputRef.current?.click()}>
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
{bulk.placed != null ? t('Choisir un autre dossier') : t('Choisir le dossier des photos')}
@@ -221,12 +267,17 @@ export default function PhotoStartPanel({
{bulk.placed != null && !bulk.running && (
<p className="pz-success" role="status">
{plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
{(bulk.placed > 0 || bulk.added === 0) && plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
{bulk.added > 0 && `${bulk.placed > 0 ? ' ' : ''}${plural(bulk.added, '1 fichier ajouté aux souvenirs.', '{n} fichiers ajoutés aux souvenirs.')}`}
{bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`}
</p>
)}
{!bulk.running && remaining.length > 0 && (
<p className="pz-hint">{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}</p>
{!bulk.running && somethingLeft && (
<p className="pz-hint">
{remainingMedia.length > 0
? t('Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.')
: t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}
</p>
)}
</section>
)}
@@ -271,7 +322,9 @@ export default function PhotoStartPanel({
<div className="pz-modal-actions pz-modal-actions--split">
<p className="pz-small">
{addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')}
{memoriesTotal > 0
? plural(addedCount + memoriesTotal, '1 fichier ajouté.', '{n} fichiers ajoutés.')
: addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')}
</p>
<button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={bulk.running}>
{t("Voir l'arbre")}
+19
View File
@@ -24,6 +24,25 @@ export default {
"{n} portraits placés.": "{n} portraits placed.",
"{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).": "{f} couldn't be sent (unsupported format or file too large).",
"Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.": "Only the photos that match a person are sent, and only to your tree.",
// Autres fichiers cités par le logiciel (actes, photos de famille) : des souvenirs
"Votre logiciel associait une photo à 1 personne, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place le portrait et ajoute le reste aux souvenirs.": "Your software linked a photo to 1 person, and also listed {m} other files, records or family photos. Choose the folder where it keeps them, Parenthèse places the portrait and adds the rest to the memories.",
"Votre logiciel associait une photo à {n} personnes, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place les portraits et ajoute le reste aux souvenirs.": "Your software linked a photo to {n} people, and also listed {m} other files, records or family photos. Choose the folder where it keeps them, Parenthèse places the portraits and adds the rest to the memories.",
"Votre logiciel citait 1 fichier, un acte ou une photo de famille. Choisissez le dossier où il le range, Parenthèse l’ajoute aux souvenirs.": "Your software listed 1 file, a record or a family photo. Choose the folder where it keeps it, Parenthèse adds it to the memories.",
"Votre logiciel citait {n} fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse les ajoute aux souvenirs de chaque personne.": "Your software listed {n} files, records or family photos. Choose the folder where it keeps them, Parenthèse adds them to each person's memories.",
"1 fichier retrouvé sur {k}.": "1 file found out of {k}.",
"{n} fichiers retrouvés sur {k}.": "{n} files found out of {k}.",
"{portraits} et {files} retrouvés.": "{portraits} and {files} found.",
"1 portrait": "1 portrait",
"{n} portraits": "{n} portraits",
"1 autre fichier": "1 other file",
"{n} autres fichiers": "{n} other files",
"Ajouter ce fichier": "Add this file",
"1 fichier ajouté.": "1 file added.",
"{n} fichiers ajoutés.": "{n} files added.",
"Tout ajouter ({n})": "Add everything ({n})",
"1 fichier ajouté aux souvenirs.": "1 file added to the memories.",
"{n} fichiers ajoutés aux souvenirs.": "{n} files added to the memories.",
"Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.": "Only the files listed by your software are sent, and only to your tree.",
"Ou commencez par ces visages": "Or start with these faces",
"Commencez par ces visages": "Start with these faces",
"Envoi": "Sending",
+25 -1
View File
@@ -56,7 +56,8 @@ export function matchPhotoFiles(photoRefs, files) {
bestScore = score
}
}
matches.push({ personId: ref.personId, file: best })
// Les autres champs de la référence (légende, source d'un souvenir) suivent le fichier retrouvé
matches.push({ ...ref, file: best, sourcePath: ref.file })
}
return { matches, missing }
}
@@ -158,6 +159,29 @@ export function savePhotoRefs(treeId, refs) {
}
}
// Les autres fichiers cités (actes, photos de famille) : gardés à part, pour rouvrir l'étape plus tard
const MEDIA_REFS_KEY = (treeId) => `parenthese_media_refs:${treeId}`
export function saveMediaRefs(treeId, refs) {
if (!treeId) return
try {
if (refs?.length) localStorage.setItem(MEDIA_REFS_KEY(treeId), JSON.stringify(refs))
else localStorage.removeItem(MEDIA_REFS_KEY(treeId))
} catch {
// Stockage plein ou indisponible : l'étape ne se rouvrira pas, l'import reste fait
}
}
export function loadMediaRefs(treeId) {
if (!treeId) return []
try {
const parsed = JSON.parse(localStorage.getItem(MEDIA_REFS_KEY(treeId)) || '[]')
return Array.isArray(parsed) ? parsed.filter((r) => r && r.personId && r.file) : []
} catch {
return []
}
}
export function loadPhotoRefs(treeId) {
if (!treeId) return []
try {
+18
View File
@@ -75,3 +75,21 @@ describe('sizeBucket', () => {
expect([0, 3, 12, 80, 450, 2697].map(sizeBucket)).toEqual(['0', '1-9', '10-49', '50-199', '200-999', '1000+'])
})
})
describe('souvenirs cités par le logiciel', () => {
it('garde la légende et la source avec le fichier retrouvé', () => {
const refs = [{ personId: 'a', file: 'C:\\Heredis\\Médias\\Naissance Ragot.jpg', caption: 'Naissance Ragot', source: 'Registre des naissances, Acte n° 12' }]
const { matches } = matchPhotoFiles(refs, [file('Naissance Ragot.jpg', 'Médias/Naissance Ragot.jpg')])
expect(matches).toHaveLength(1)
expect(matches[0]).toMatchObject({ personId: 'a', caption: 'Naissance Ragot', source: 'Registre des naissances, Acte n° 12' })
expect(matches[0].file.name).toBe('Naissance Ragot.jpg')
})
})
describe('chemin d’origine', () => {
it('chaque correspondance garde le chemin cité, même pour trois fichiers de la même personne', () => {
const refs = ['a.jpg', 'b.pdf', 'c.jpg'].map((name) => ({ personId: 'p', file: `Medias/${name}` }))
const { matches } = matchPhotoFiles(refs, ['a.jpg', 'b.pdf', 'c.jpg'].map((name) => file(name, `Medias/${name}`)))
expect(matches.map((m) => m.sourcePath)).toEqual(['Medias/a.jpg', 'Medias/b.pdf', 'Medias/c.jpg'])
})
})