mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 15:52:14 +00:00
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:
1 parent
edbbf7b163
commit
82f888b1dc
3 files changed
+76
-30
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user