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() + }) +}) 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..326b7f5 100644 --- a/frontend/src/components/PhotoStartPanel.jsx +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -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 && (

{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 +234,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 ? (
)} @@ -271,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.')}