diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index d790a82..9388577 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -148,10 +148,51 @@ export function plainNoteText(raw: string): string { return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim() } +// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois +// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux) +const noteTextCache = new WeakMap() +const recordTitleCache = new WeakMap() +const hasMediaCache = new WeakMap() +const MAX_RECORD_DEPTH = 200 +const MAX_WALKED_NODES_PER_PERSON = 5000 + export 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)) + const target = pointer ? records.note.get(pointer) : node + if (!target) return '' + let text = noteTextCache.get(target) + if (text === undefined) { + text = plainNoteText(joinedText(target)) + noteTextCache.set(target, text) + } + return text +} + +function recordTitle(record: GedcomPhotoNode): string { + let title = recordTitleCache.get(record) + if (title === undefined) { + title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR')) + recordTitleCache.set(record, title) + } + return title +} + +// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ? +// Un renvoi en boucle compte pour non pendant le calcul. +function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean { + const known = hasMediaCache.get(node) + if (known !== undefined) return known + if (depth > MAX_RECORD_DEPTH) return false + hasMediaCache.set(node, false) + let found = false + for (const c of node.children) { + const pointer = pointerOf(c.value) + const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined + if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break } + } + hasMediaCache.set(node, found) + return found } function clipText(value: string, max: number): string | null { @@ -170,7 +211,7 @@ function baseNameWithoutExtension(path: string): string { export 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 title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value) const page = joinedText(child(citation, 'PAGE')) return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH) } @@ -203,32 +244,37 @@ export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords } } - const walk = (node: GedcomPhotoNode, source: string | null) => { + let budget = MAX_WALKED_NODES_PER_PERSON + const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => { + if (depth > MAX_RECORD_DEPTH) return for (const c of node.children) { + if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return 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) + const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null + if (!hasMedia(c, records) && !recordToWalk) continue + const label = sourceLabel(c, records) ?? source + walk(c, label, depth + 1) + if (recordToWalk) { + visitedRecords.add(recordToWalk) + walk(recordToWalk, label, depth + 1) } } else if (c.tag === 'FAMS') { const pointer = pointerOf(c.value) const fam = pointer ? records.fam.get(pointer) : null - if (fam && !visitedRecords.has(fam)) { + if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) { visitedRecords.add(fam) - walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source) + walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1) } - } else if (!pointerOf(c.value) && c.children.length > 0) { - walk(c, source) + } else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) { + walk(c, source, depth + 1) } } } - walk(indi, null) + walk(indi, null, 0) return refs } diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 5fe070c..00cb2c7 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { UPLOAD_BODY_LIMIT } from '../config/env.js' import { parseGedcomDate } from '../lib/gedcom-dates.js' import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js' -import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js' +import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js' import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' @@ -20,6 +20,7 @@ const importBodySchema = z.object({ // Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs // plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base) const MAX_LINE_LENGTH = 4096 +const MAX_GEDCOM_LEVEL = 99 const MAX_IMPORTED_PERSONS = 20000 const MAX_IMPORTED_FAMILIES = 20000 // Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le @@ -94,6 +95,8 @@ export function parseGedcom(text: string): GedcomNode[] { if (!match) continue const level = parseInt(match[1], 10) + // La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile + if (level > MAX_GEDCOM_LEVEL) continue const xref = match[2] ? match[2].slice(1, -1) : undefined const tag = match[3] const rawValue = match[4] ?? '' @@ -137,19 +140,7 @@ function gedcomDateToIso(value: string): string | null { // Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC), // RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide. export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null { - const texts: string[] = [] - for (const note of findAllChildren(indi, 'NOTE')) { - const pointer = note.value.match(/^@([^@]+)@$/)?.[1] - const source = pointer ? records.note.get(pointer) : note - if (!source) continue - let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value) - for (const part of source.children) { - if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}` - else if (part.tag === 'CONC') text += part.rawValue ?? part.value - } - const clean = plainNoteText(text) - if (clean) texts.push(clean) - } + const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean) return texts.length ? texts.join('\n\n') : null } @@ -675,8 +666,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200) - const notes = clip(personNotes(indi, records), 2000) - // Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre const personFirstName = (firstName || '').slice(0, 120) const personLastName = (lastName || '').slice(0, 120) @@ -693,6 +682,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { continue } + const notes = clip(personNotes(indi, records), 2000) const { events, ...extras } = personExtras(indi, records) const person = await tx.person.create({ data: { @@ -720,7 +710,8 @@ 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)) { + const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : [] + for (const ref of personMedia) { if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break mediaRefs.push({ personId: person.id, ...ref }) } diff --git a/backend/test/gedcom-media.test.ts b/backend/test/gedcom-media.test.ts index 86923e0..5b1aa26 100644 --- a/backend/test/gedcom-media.test.ts +++ b/backend/test/gedcom-media.test.ts @@ -220,4 +220,13 @@ describe('localMediaPath', () => { expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull() expect(localMediaPath('')).toBeNull() }) + + it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => { + const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat() + const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`) + const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n') + const started = Date.now() + expect(() => mediaOf(text)).not.toThrow() + expect(Date.now() - started).toBeLessThan(2000) + }) })