mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-11 08:02:17 +00:00
Merge pull request #68 from IliesAllali/gedcom-souvenirs
Import GEDCOM : les fichiers cités deviennent des souvenirs (#59)
This commit is contained in:
8 files changed
+619
-38
No files matched your search
@@ -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, GedcomPhotoNode>,
|
||||
): 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<string, GedcomPh
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------- Souvenirs : tous les fichiers cités pour une personne ----------
|
||||
// Un logiciel de généalogie accroche ses fichiers à plusieurs endroits : sous la personne (« 1 OBJE »), sous
|
||||
// un événement (« 1 BIRT » / « 2 OBJE »), sous une source citée (« 2 SOUR @S1@ » / « 3 OBJE »), dans la
|
||||
// source elle-même (« 0 @S1@ SOUR » / « 1 OBJE ») ou sous la famille (actes de mariage). On les ramasse
|
||||
// tous, comme Gramps (libgedcom.py), pour que les actes, registres et photos de famille deviennent des
|
||||
// souvenirs. Le portrait reste à part (photoPathForPerson).
|
||||
|
||||
export type GedcomMediaRef = { file: string; caption: string | null; source: string | null }
|
||||
|
||||
const MEDIA_EXTENSIONS = new Set([
|
||||
...IMAGE_EXTENSIONS,
|
||||
'pdf', 'txt',
|
||||
'mp4', 'mov', 'm4v', 'webm', 'avi',
|
||||
'mp3', 'm4a', 'wav', 'ogg', 'aac', 'flac',
|
||||
])
|
||||
const MAX_MEDIA_PER_PERSON = 60
|
||||
const MAX_CAPTION_LENGTH = 280
|
||||
const MAX_SOURCE_LENGTH = 500
|
||||
|
||||
export type GedcomRecords = {
|
||||
obje: Map<string, GedcomPhotoNode>
|
||||
sour: Map<string, GedcomPhotoNode>
|
||||
note: Map<string, GedcomPhotoNode>
|
||||
fam: Map<string, GedcomPhotoNode>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
if (portraitPath) seenPaths.add(portraitPath.toLowerCase())
|
||||
const visitedRecords = new Set<GedcomPhotoNode>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, string>()
|
||||
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 })
|
||||
})
|
||||
}
|
||||
@@ -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<string, { portrait: string | null; media: ReturnType<typeof mediaRefsForPerson> }> = {}
|
||||
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()
|
||||
})
|
||||
})
|
||||
+62
-6
@@ -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) */}
|
||||
|
||||
@@ -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 && (
|
||||
<section className="psp-block psp-block--folder" aria-labelledby="pspFolderTitle">
|
||||
<h3 id="pspFolderTitle" className="psp-h">{t('Vos photos sont déjà sur votre ordinateur')}</h3>
|
||||
{remaining.length > 0 ? (
|
||||
{somethingLeft ? (
|
||||
<>
|
||||
<p className="psp-p">
|
||||
{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.')}
|
||||
</p>
|
||||
<p className="psp-p">{intro()}</p>
|
||||
{exampleFolder && (
|
||||
<p className="pz-hint">{t('Il les rangeait dans {folder}', { folder: exampleFolder })}</p>
|
||||
)}
|
||||
@@ -190,12 +234,14 @@ export default function PhotoStartPanel({
|
||||
</div>
|
||||
) : found ? (
|
||||
<div className="psp-found">
|
||||
{found.matches.length > 0 ? (
|
||||
{foundTotal > 0 ? (
|
||||
<>
|
||||
<p className="psp-p"><strong>{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}</strong></p>
|
||||
<p className="psp-p"><strong>{foundSummary()}</strong></p>
|
||||
<div className="psp-row-actions">
|
||||
<button type="button" className="pz-btn pz-btn--accent" onClick={handlePlace}>
|
||||
{plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
|
||||
{foundMedia.length > 0
|
||||
? plural(foundTotal, 'Ajouter ce fichier', 'Tout ajouter ({n})')
|
||||
: plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => folderInputRef.current?.click()}>
|
||||
{t('Un autre dossier')}
|
||||
@@ -212,7 +258,7 @@ export default function PhotoStartPanel({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : remaining.length > 0 ? (
|
||||
) : somethingLeft ? (
|
||||
<button type="button" className="pz-btn pz-btn--accent psp-folder-btn" onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
|
||||
{bulk.placed != null ? t('Choisir un autre dossier') : t('Choisir le dossier des photos')}
|
||||
@@ -221,12 +267,17 @@ export default function PhotoStartPanel({
|
||||
|
||||
{bulk.placed != null && !bulk.running && (
|
||||
<p className="pz-success" role="status">
|
||||
{plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
|
||||
{(bulk.placed > 0 || bulk.added === 0) && plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
|
||||
{bulk.added > 0 && `${bulk.placed > 0 ? ' ' : ''}${plural(bulk.added, '1 fichier ajouté aux souvenirs.', '{n} fichiers ajoutés aux souvenirs.')}`}
|
||||
{bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`}
|
||||
</p>
|
||||
)}
|
||||
{!bulk.running && remaining.length > 0 && (
|
||||
<p className="pz-hint">{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}</p>
|
||||
{!bulk.running && somethingLeft && (
|
||||
<p className="pz-hint">
|
||||
{remainingMedia.length > 0
|
||||
? t('Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.')
|
||||
: t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -271,7 +322,9 @@ export default function PhotoStartPanel({
|
||||
|
||||
<div className="pz-modal-actions pz-modal-actions--split">
|
||||
<p className="pz-small">
|
||||
{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.')}
|
||||
</p>
|
||||
<button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={bulk.running}>
|
||||
{t("Voir l'arbre")}
|
||||
|
||||
@@ -24,6 +24,25 @@ 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",
|
||||
"1 fichier ajouté.": "1 file added.",
|
||||
"{n} fichiers ajoutés.": "{n} files added.",
|
||||
"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",
|
||||
|
||||
@@ -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, sourcePath: ref.file })
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -75,3 +75,21 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chemin d’origine', () => {
|
||||
it('chaque correspondance garde le chemin cité, même pour trois fichiers de la même personne', () => {
|
||||
const refs = ['a.jpg', 'b.pdf', 'c.jpg'].map((name) => ({ personId: 'p', file: `Medias/${name}` }))
|
||||
const { matches } = matchPhotoFiles(refs, ['a.jpg', 'b.pdf', 'c.jpg'].map((name) => file(name, `Medias/${name}`)))
|
||||
expect(matches.map((m) => m.sourcePath)).toEqual(['Medias/a.jpg', 'Medias/b.pdf', 'Medias/c.jpg'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user