perf(gedcom): notes et sources partagees lues une fois par import

Un fichier dont chaque fiche cite une note ou une source enorme faisait relire
ce texte pour chaque personne : environ 35 min de calcul pour 20 000 fiches,
0,1 s maintenant (memo par noeud, pour la duree de l'import).

- La lecture des medias ne descend que dans les branches qui menent a un
  fichier, avec un budget par personne et une profondeur bornee.
- Les niveaux au-dela de 99 (limite de la norme) sont ignores : une chaine de
  sources faisait deborder la pile.
- Les notes ne sont calculees que pour une personne creee, pas reutilisee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-19 00:06:29 +02:00
1 parent edbbf7b163
commit 82f888b1dc
3 files changed
+76 -30

No files matched your search

+59 -13
View File
@@ -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<GedcomPhotoNode, string>()
const recordTitleCache = new WeakMap<GedcomPhotoNode, string>()
const hasMediaCache = new WeakMap<GedcomPhotoNode, boolean>()
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
}
+8 -17
View File
@@ -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 })
}
+9
View File
@@ -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)
})
})