From 897ada26650316cc1139e43b6ec37b9e68625a91 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 19:55:56 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Import=20GEDCOM=20:=20tous=20les=20fichiers?= =?UTF-8?q?=20cit=C3=A9s=20deviennent=20des=20souvenirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En plus du portrait, l'import renvoie mediaRefs : les fichiers cités pour chaque personne créée, où qu'ils soient accrochés (sous la personne, un événement, une source citée, la fiche de la source, ou la famille pour les actes de mariage), avec leur titre et leur source. Comme Gramps : adresses web écartées, un fichier cité deux fois gardé une fois. Particularités gérées : titre dans la note (Heredis, RTF compris), _PHOTO pour le portrait (Family Tree Maker), chemins file:// (GEDCOM 7). Pour #59. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/gedcom-photos.ts | 183 +++++++++++++++++++++++- backend/src/routes/gedcom.ts | 21 ++- backend/test/gedcom-media.test.ts | 223 ++++++++++++++++++++++++++++++ 3 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 backend/test/gedcom-media.test.ts diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index 91ee298..d8c18a1 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -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 | 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 + sour: Map + note: Map + fam: Map +} + +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() + if (portraitPath) seenPaths.add(portraitPath.toLowerCase()) + const visitedRecords = new Set() + + 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 +} diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index f655653..036a92e 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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() - 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 }) }) } diff --git a/backend/test/gedcom-media.test.ts b/backend/test/gedcom-media.test.ts new file mode 100644 index 0000000..86923e0 --- /dev/null +++ b/backend/test/gedcom-media.test.ts @@ -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 }> = {} + 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() + }) +}) From 3097768af5d5df7af886117f89e9ccad447db53e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 20:00:40 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Premi=C3=A8res=20photos=20:=20les=20autres?= =?UTF-8?q?=20fichiers=20cit=C3=A9s=20deviennent=20des=20souvenirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À l'étape du dossier de photos, les fichiers cités par le logiciel en plus des portraits (actes, registres, photos de famille) sont retrouvés dans le même dossier et ajoutés aux souvenirs de chaque personne, avec le titre et la source venus du GEDCOM. Un seul bouton « Tout ajouter » ; les fichiers restants sont gardés pour rouvrir l'étape depuis les paramètres. Pour #59. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.jsx | 68 +++++++++++++-- frontend/src/components/PhotoStartPanel.jsx | 93 ++++++++++++++++----- frontend/src/i18n/en/photos.js | 17 ++++ frontend/src/utils/photoImport.js | 26 +++++- frontend/src/utils/photoImport.test.js | 10 +++ 5 files changed, 184 insertions(+), 30 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a91e7b9..c8a82f0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) */} diff --git a/frontend/src/components/PhotoStartPanel.jsx b/frontend/src/components/PhotoStartPanel.jsx index 1a224bd..ed1ac6e 100644 --- a/frontend/src/components/PhotoStartPanel.jsx +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -33,22 +33,28 @@ 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. +const refKey = (ref) => `${ref.personId}|${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) @@ -57,8 +63,9 @@ export default function PhotoStartPanel({ 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) @@ -80,7 +87,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 +95,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))) @@ -112,8 +132,8 @@ export default function PhotoStartPanel({ setRemaining(nextRemaining) onRefsChange?.(nextRemaining) setFound(null) - setPlacedTotal((n) => n + placedIds.size) - setBulk({ running: false, done: total, total, placed: placedIds.size, failed: total - placedIds.size }) + setPlacedTotal((n) => n + placedIds.size + 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 +157,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 +201,9 @@ export default function PhotoStartPanel({ {refsKnown && (

{t('Vos photos sont déjà sur votre ordinateur')}

- {remaining.length > 0 ? ( + {somethingLeft ? ( <> -

- {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.')} -

+

{intro()}

{exampleFolder && (

{t('Il les rangeait dans {folder}', { folder: exampleFolder })}

)} @@ -190,12 +230,14 @@ export default function PhotoStartPanel({ ) : found ? (
- {found.matches.length > 0 ? ( + {foundTotal > 0 ? ( <> -

{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}

+

{foundSummary()}

- ) : remaining.length > 0 ? ( + ) : somethingLeft ? (
)} diff --git a/frontend/src/i18n/en/photos.js b/frontend/src/i18n/en/photos.js index c4b462c..7f4c043 100644 --- a/frontend/src/i18n/en/photos.js +++ b/frontend/src/i18n/en/photos.js @@ -24,6 +24,23 @@ 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", + "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", diff --git a/frontend/src/utils/photoImport.js b/frontend/src/utils/photoImport.js index 644540f..0164993 100644 --- a/frontend/src/utils/photoImport.js +++ b/frontend/src/utils/photoImport.js @@ -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 }) } 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 { diff --git a/frontend/src/utils/photoImport.test.js b/frontend/src/utils/photoImport.test.js index c0185be..c2972ba 100644 --- a/frontend/src/utils/photoImport.test.js +++ b/frontend/src/utils/photoImport.test.js @@ -75,3 +75,13 @@ 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') + }) +}) From 2fccf287b3cc5a7b7bf853f14f1e77331b4fd012 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 20:06:03 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Premi=C3=A8res=20photos=20:=20compter=20cha?= =?UTF-8?q?que=20souvenir=20par=20son=20chemin=20d'origine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les correspondances gardent le chemin cité par le logiciel (sourcePath) : plusieurs fichiers d'une même personne ne se confondent plus, le décompte des fichiers ajoutés et restants est juste, et le pied du panneau parle de fichiers quand des documents ont été ajoutés. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/PhotoStartPanel.jsx | 12 +++++++++--- frontend/src/i18n/en/photos.js | 2 ++ frontend/src/utils/photoImport.js | 2 +- frontend/src/utils/photoImport.test.js | 8 ++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/PhotoStartPanel.jsx b/frontend/src/components/PhotoStartPanel.jsx index ed1ac6e..326b7f5 100644 --- a/frontend/src/components/PhotoStartPanel.jsx +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -33,7 +33,8 @@ 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. -const refKey = (ref) => `${ref.personId}|${ref.file}` +// 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, @@ -58,6 +59,7 @@ export default function PhotoStartPanel({ 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(() => { @@ -69,6 +71,7 @@ export default function PhotoStartPanel({ setFaceState({}) setShownFaces(faces) setPlacedTotal(0) + setMemoriesTotal(0) }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -132,7 +135,8 @@ export default function PhotoStartPanel({ setRemaining(nextRemaining) onRefsChange?.(nextRemaining) setFound(null) - setPlacedTotal((n) => n + placedIds.size + addedKeys.size) + setPlacedTotal((n) => n + 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 }) } @@ -318,7 +322,9 @@ export default function PhotoStartPanel({

- {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.')}