Événements de vie : table, migration, import GEDCOM

Nouvelle table life_events (baptême, résidence, métiers, études, service
militaire, inhumation, divorce, événements libres…) avec date, précision,
lieu, description, notes et sources. Personne : précision et borne des
dates de naissance et de décès, lieu de décès, surnom. Union : lieu et
précision du mariage. Naissance, décès et mariage gardent leur date et leur
lieu sur la personne ou l'union ; leur ligne d'événement ne porte que les
sources et les notes. Migration additive.

L'import lit tous ces événements (balises de la norme, variantes Gramps,
Heredis, RootsMagic), les adresses complètes, et les adoptions déclarées
par PEDI. Une personne ou une union déjà présente ne reçoit rien en double.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 22:16:45 +02:00
1 parent dd738c483a
commit e24c8c86d6
6 files changed
+510 -119

No files matched your search

@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "birthDateEnd" TIMESTAMP(3),
ADD COLUMN "birthDatePrecision" TEXT,
ADD COLUMN "deathDateEnd" TIMESTAMP(3),
ADD COLUMN "deathDatePrecision" TEXT,
ADD COLUMN "deathPlace" TEXT,
ADD COLUMN "nickname" TEXT;
-- AlterTable
ALTER TABLE "unions" ADD COLUMN "startDatePrecision" TEXT,
ADD COLUMN "startPlace" TEXT;
-- CreateTable
CREATE TABLE "life_events" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"personId" TEXT,
"unionId" TEXT,
"type" TEXT NOT NULL,
"label" TEXT,
"date" TIMESTAMP(3),
"dateEnd" TIMESTAMP(3),
"datePrecision" TEXT,
"dateText" TEXT,
"place" TEXT,
"description" TEXT,
"notes" TEXT,
"sources" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "life_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "life_events_treeId_idx" ON "life_events"("treeId");
-- CreateIndex
CREATE INDEX "life_events_treeId_personId_idx" ON "life_events"("treeId", "personId");
-- CreateIndex
CREATE INDEX "life_events_treeId_unionId_idx" ON "life_events"("treeId", "unionId");
-- AddForeignKey
ALTER TABLE "life_events" ADD CONSTRAINT "life_events_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+154 -114
View File
@@ -78,21 +78,21 @@ enum SharedAccessRole {
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
lastOpenedTreeId String?
lastOpenedAccessMode String?
lastOpenedRole String?
lastOpenedAt DateTime?
createdAt DateTime @default(now())
ownedTrees Tree[] @relation("TreeOwner")
memberships TreeMembership[]
sharedTreeAccesses UserTreeAccess[]
id String @id @default(cuid())
email String @unique
passwordHash String
lastOpenedTreeId String?
lastOpenedAccessMode String?
lastOpenedRole String?
lastOpenedAt DateTime?
createdAt DateTime @default(now())
ownedTrees Tree[] @relation("TreeOwner")
memberships TreeMembership[]
sharedTreeAccesses UserTreeAccess[]
submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy")
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
treeVisits TreeVisit[]
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
treeVisits TreeVisit[]
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
@@index([lastOpenedAt])
@@map("users")
@@ -123,6 +123,7 @@ model Tree {
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
lifeEvents LifeEvent[]
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
@@index([ownerUserId])
@@ -151,14 +152,14 @@ model TreeSharePassword {
}
model TreeMembership {
id String @id @default(cuid())
id String @id @default(cuid())
treeId String
userId String
role MembershipRole
contributionModeOverride ContributionPolicy?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([treeId, userId])
@@index([userId])
@@ -166,14 +167,14 @@ model TreeMembership {
}
model UserTreeAccess {
id String @id @default(cuid())
treeId String
userId String
role SharedAccessRole
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
treeId String
userId String
role SharedAccessRole
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([treeId, userId])
@@index([userId, updatedAt])
@@ -181,12 +182,12 @@ model UserTreeAccess {
}
model TreeSettings {
treeId String @id
memberContributionPolicy ContributionPolicy @default(pending)
visitorPolicy VisitorPolicy @default(read_only)
contributorPolicy ContributionPolicy @default(pending)
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
treeId String @id
memberContributionPolicy ContributionPolicy @default(pending)
visitorPolicy VisitorPolicy @default(read_only)
contributorPolicy ContributionPolicy @default(pending)
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@map("tree_settings")
}
@@ -205,89 +206,98 @@ model TreeImmichConfig {
}
model Person {
id String @id @default(cuid())
treeId String
firstName String
lastName String
birthName String?
birthDate DateTime?
deathDate DateTime?
birthPlace String?
profession String?
region String?
nationality String?
avatarPath String?
avatarMimeType String?
sex String?
notes String?
createdBy String?
updatedBy String?
id String @id @default(cuid())
treeId String
firstName String
lastName String
birthName String?
birthDate DateTime?
deathDate DateTime?
birthPlace String?
// Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … »
birthDatePrecision String?
birthDateEnd DateTime?
deathDatePrecision String?
deathDateEnd DateTime?
deathPlace String?
nickname String?
profession String?
region String?
nationality String?
avatarPath String?
avatarMimeType String?
sex String?
notes String?
createdBy String?
updatedBy String?
// Personne Immich associée (reconnaissance des visages) : identifiant côté Immich, ou null.
immichPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
immichPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("persons")
}
model Union {
id String @id @default(cuid())
treeId String
partner1PersonId String
partner2PersonId String?
unionType String?
startDate DateTime?
endDate DateTime?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
treeId String
partner1PersonId String
partner2PersonId String?
unionType String?
startDate DateTime?
endDate DateTime?
displayOrder Int @default(1)
startPlace String?
startDatePrecision String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("unions")
}
model ParentChildLink {
id String @id @default(cuid())
treeId String
id String @id @default(cuid())
treeId String
parentPersonId String
childPersonId String
viaUnionId String?
parentageType String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
childPersonId String
viaUnionId String?
parentageType String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("parent_child_links")
}
model MediaItem {
id String @id @default(cuid())
treeId String
personId String
type String
mimeType String
filePath String
thumbPath String?
caption String?
source String?
sizeBytes Int
displayOrder Int @default(1)
isFeatured Boolean @default(false)
uploadedBy String?
status MediaStatus @default(approved)
id String @id @default(cuid())
treeId String
personId String
type String
mimeType String
filePath String
thumbPath String?
caption String?
source String?
sizeBytes Int
displayOrder Int @default(1)
isFeatured Boolean @default(false)
uploadedBy String?
status MediaStatus @default(approved)
contributionSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, personId, displayOrder])
@@map("media_items")
@@ -316,17 +326,17 @@ model ContributionSession {
}
model ContributionChange {
id String @id @default(cuid())
sessionId String
entityType ContributionEntityType
entityId String?
action ContributionAction
beforeJson Json?
afterJson Json?
conflictState ConflictState @default(none)
decision ContributionDecision?
createdAt DateTime @default(now())
session ContributionSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
sessionId String
entityType ContributionEntityType
entityId String?
action ContributionAction
beforeJson Json?
afterJson Json?
conflictState ConflictState @default(none)
decision ContributionDecision?
createdAt DateTime @default(now())
session ContributionSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@map("contribution_changes")
@@ -352,16 +362,16 @@ model Annotation {
}
model AuditLog {
id String @id @default(cuid())
treeId String
actorType String
actorId String?
action String
entityType String
entityId String
id String @id @default(cuid())
treeId String
actorType String
actorId String?
action String
entityType String
entityId String
payloadJson Json?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, createdAt])
@@map("audit_log")
@@ -395,3 +405,33 @@ model WaitlistEntry {
@@map("waitlist_entries")
}
// Événements de vie d'une personne (baptême, résidence, métier, études, service militaire, inhumation…) ou
// d'un couple (divorce…). Naissance, décès et mariage gardent leur date et leur lieu sur la personne ou
// l'union ; leur ligne ici ne porte que les sources et les notes (type birth, death, marriage).
model LifeEvent {
id String @id @default(cuid())
treeId String
personId String?
unionId String?
type String
label String?
date DateTime?
dateEnd DateTime?
datePrecision String?
dateText String?
place String?
description String?
notes String?
sources String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@index([treeId, personId])
@@index([treeId, unionId])
@@map("life_events")
}
+189
View File
@@ -0,0 +1,189 @@
// Événements de vie lus dans un GEDCOM. Naissance, décès et mariage gardent leur date et leur lieu sur la
// personne ou l'union : on n'en tire ici que la précision, le lieu de décès, les sources et les notes.
// Les autres événements (baptême, résidence, métier, inhumation, divorce…) deviennent des LifeEvent.
import { parseGedcomDate } from './gedcom-dates.js'
import { joinedText, noteText, sourceLabel } from './gedcom-photos.js'
import type { GedcomPhotoNode, GedcomRecords } from './gedcom-photos.js'
type Node = GedcomPhotoNode
export type ImportedEvent = {
type: string
label: string | null
date: Date | null
dateEnd: Date | null
datePrecision: string | null
dateText: string | null
place: string | null
description: string | null
notes: string | null
sources: string | null
}
// Balises de la norme et variantes courantes (Gramps, Heredis, Geneatique, RootsMagic)
export const PERSON_EVENT_TYPES: Record<string, string> = {
BAPM: 'baptism', CHR: 'baptism', BURI: 'burial', CREM: 'cremation', RESI: 'residence', OCCU: 'occupation',
EDUC: 'education', GRAD: 'graduation', RELI: 'religion', CONF: 'confirmation', FCOM: 'first_communion',
BARM: 'bar_mitzvah', BASM: 'bat_mitzvah', ORDN: 'ordination', ADOP: 'adoption', EMIG: 'emigration',
IMMI: 'immigration', NATU: 'naturalization', CENS: 'census', TITL: 'title', PROP: 'property',
RETI: 'retirement', WILL: 'will', PROB: 'probate', _MILT: 'military', _MDCL: 'medical', _DEG: 'degree',
EVEN: 'custom', FACT: 'custom',
}
export const FAMILY_EVENT_TYPES: Record<string, string> = {
DIV: 'divorce', DIVF: 'divorce_filed', ANUL: 'annulment', ENGA: 'engagement', MARB: 'marriage_banns',
MARC: 'marriage_contract', MARL: 'marriage_license', MARS: 'marriage_settlement', EVEN: 'custom', FACT: 'custom',
}
const MAX_EVENTS_PER_OWNER = 200
const clip = (value: string | null | undefined, max: number) => {
const text = (value ?? '').replace(/\s+\n/g, '\n').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
const child = (node: Node | undefined, tag: string) => node?.children.find((c) => c.tag === tag)
// Même règle que le lieu de naissance : noms seulement, sans case vide ni code INSEE ou postal
export function cleanPlace(value: string | null | undefined): string | null {
if (!value) return null
const parts: string[] = []
for (const raw of value.split(',')) {
const part = raw.trim()
if (!part || /^(\d{4,5}|2[AB]\d{3})$/i.test(part)) continue
if (parts.length && parts[parts.length - 1].toLowerCase() === part.toLowerCase()) continue
parts.push(part)
}
return parts.length ? parts.join(', ') : null
}
export function isoToDbDate(iso: string | null | undefined): Date | null {
if (!iso) return null
if (/^\d{4}$/.test(iso)) return new Date(`${iso}-01-01T00:00:00.000Z`)
if (/^\d{4}-\d{2}$/.test(iso)) return new Date(`${iso}-01T00:00:00.000Z`)
return new Date(`${iso}T00:00:00.000Z`)
}
function eventNotes(node: Node, records: GedcomRecords): string | null {
const texts = node.children.filter((c) => c.tag === 'NOTE').map((n) => noteText(n, records)).filter(Boolean)
return clip(texts.join('\n\n'), 4000)
}
function eventSources(node: Node, records: GedcomRecords): string | null {
const labels = node.children.filter((c) => c.tag === 'SOUR').map((s) => sourceLabel(s, records)).filter(Boolean) as string[]
return clip([...new Set(labels)].join('\n'), 4000)
}
// Ce qu'on lit d'un événement : date et précision, lieu (ou adresse), description, notes, sources
export function readEvent(node: Node, type: string, records: GedcomRecords): ImportedEvent {
const dateText = child(node, 'DATE')?.value ?? null
const parsed = parseGedcomDate(dateText)
const addr = child(node, 'ADDR')
const address = [joinedText(addr).replace(/\n+/g, ', '), ...['CITY', 'STAE', 'CTRY'].map((tag) => child(addr, tag)?.value ?? '')]
.map((part) => part.trim())
.filter(Boolean)
.join(', ')
const place = cleanPlace(child(node, 'PLAC')?.value) ?? clip(address, 300)
const typeLabel = joinedText(child(node, 'TYPE'))
const value = node.value && node.value.toUpperCase() !== 'Y' ? node.value : ''
return {
type,
label: type === 'custom' ? clip(typeLabel || value, 120) : null,
date: isoToDbDate(parsed?.iso),
dateEnd: isoToDbDate(parsed?.endIso),
datePrecision: parsed && parsed.precision !== 'exact' ? parsed.precision : null,
dateText: parsed && parsed.precision === 'exact' ? null : clip(dateText, 120),
place: clip(place, 300),
description: clip(type === 'custom' && !typeLabel ? '' : value, 1000),
notes: eventNotes(node, records),
sources: eventSources(node, records),
}
}
export type PersonExtras = {
birthDatePrecision: string | null
birthDateEnd: Date | null
deathDatePrecision: string | null
deathDateEnd: Date | null
deathPlace: string | null
nickname: string | null
events: ImportedEvent[]
}
const hasDetails = (e: ImportedEvent) => Boolean(e.notes || e.sources)
export function personExtras(indi: Node, records: GedcomRecords): PersonExtras {
const events: ImportedEvent[] = []
const birth = child(indi, 'BIRT')
const death = child(indi, 'DEAT')
const birthDate = parseGedcomDate(child(birth, 'DATE')?.value)
const deathDate = parseGedcomDate(child(death, 'DATE')?.value)
// Naissance et décès : leurs sources et notes, sans recopier la date ni le lieu
for (const [node, type] of [[birth, 'birth'], [death, 'death']] as const) {
if (!node) continue
const event = readEvent(node, type, records)
if (hasDetails(event)) events.push({ ...event, date: null, dateEnd: null, datePrecision: null, dateText: null, place: null, description: null })
}
// La première profession reste aussi sur la fiche (champ « profession ») et figure quand même ici
for (const c of indi.children) {
const type = PERSON_EVENT_TYPES[c.tag]
if (!type || events.length >= MAX_EVENTS_PER_OWNER) continue
const event = readEvent(c, type, records)
if (!event.date && !event.place && !event.description && !event.label && !hasDetails(event)) continue
events.push(event)
}
const nameNode = child(indi, 'NAME')
return {
birthDatePrecision: birthDate && birthDate.precision !== 'exact' ? birthDate.precision : null,
birthDateEnd: isoToDbDate(birthDate?.endIso),
deathDatePrecision: deathDate && deathDate.precision !== 'exact' ? deathDate.precision : null,
deathDateEnd: isoToDbDate(deathDate?.endIso),
deathPlace: clip(cleanPlace(child(death, 'PLAC')?.value), 200),
nickname: clip(child(nameNode, 'NICK')?.value ?? child(indi, 'NICK')?.value, 120),
events,
}
}
export type UnionExtras = {
startPlace: string | null
startDatePrecision: string | null
events: ImportedEvent[]
}
export function familyExtras(fam: Node, records: GedcomRecords): UnionExtras {
const marriage = child(fam, 'MARR')
const date = parseGedcomDate(child(marriage, 'DATE')?.value)
const events: ImportedEvent[] = []
if (marriage) {
const event = readEvent(marriage, 'marriage', records)
if (hasDetails(event)) events.push({ ...event, date: null, dateEnd: null, datePrecision: null, dateText: null, place: null, description: null })
}
for (const c of fam.children) {
const type = FAMILY_EVENT_TYPES[c.tag]
if (!type || events.length >= MAX_EVENTS_PER_OWNER) continue
const event = readEvent(c, type, records)
// « 1 DIV Y » sans rien d'autre dit quand même qu'il y a eu divorce
if (!event.date && !event.place && !event.description && !event.label && !hasDetails(event) && c.value.toUpperCase() !== 'Y') continue
events.push(event)
}
return {
startPlace: clip(cleanPlace(child(marriage, 'PLAC')?.value), 300),
startDatePrecision: date && date.precision !== 'exact' ? date.precision : null,
events,
}
}
// Adoption déclarée sous l'enfant : « 1 FAMC @F1@ » / « 2 PEDI adopted »
export function adoptedFamilies(indi: Node): Set<string> {
const out = new Set<string>()
for (const famc of indi.children.filter((c) => c.tag === 'FAMC')) {
const pedi = (child(famc, 'PEDI')?.value || '').toLowerCase()
const pointer = famc.value.match(/^@([^@]+)@$/)?.[1]
if (pointer && pedi === 'adopted') out.add(pointer)
}
return out
}
+3 -3
View File
@@ -123,7 +123,7 @@ export function localMediaPath(value: string): string | null {
return dot > 0 && MEDIA_EXTENSIONS.has(name.slice(dot + 1).toLowerCase()) ? path : null
}
function joinedText(node: GedcomPhotoNode | undefined): string {
export function joinedText(node: GedcomPhotoNode | undefined): string {
if (!node) return ''
let text = node.rawValue ?? node.value
for (const c of node.children) {
@@ -148,7 +148,7 @@ export function plainNoteText(raw: string): string {
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
}
function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string {
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))
@@ -167,7 +167,7 @@ function baseNameWithoutExtension(path: string): string {
}
// 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 {
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)
+19 -2
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import { parseGedcomDate } from '../lib/gedcom-dates.js'
import { adoptedFamilies, familyExtras, personExtras } from '../lib/gedcom-events.js'
import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js'
import type { GedcomRecords } from '../lib/gedcom-photos.js'
import type { MembershipRole } from '../types/auth.js'
@@ -528,6 +529,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
let personsReused = 0
let unionsCreated = 0
let linksCreated = 0
let eventsCreated = 0
// Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté
const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)]))
importInProgress = true
try {
@@ -567,6 +571,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
continue
}
const { events, ...extras } = personExtras(indi, records)
const person = await tx.person.create({
data: {
treeId,
@@ -578,10 +583,15 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
birthPlace,
profession,
notes,
...extras,
createdBy: actor.userId,
updatedBy: actor.userId,
},
})
if (events.length) {
await tx.lifeEvent.createMany({ data: events.map((event, index) => ({ ...event, treeId, personId: person.id, displayOrder: index + 1 })) })
eventsCreated += events.length
}
xrefToPersonId.set(indi.xref!, person.id)
personsCreated++
@@ -608,17 +618,23 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const secondPartner = partner1Id ? (partner2Id ?? null) : null
let unionId = unionByPair.get(pairKey(firstPartner, secondPartner))
if (!unionId) {
const { events: familyEvents, ...unionExtras } = familyExtras(fam, records)
const union = await tx.union.create({
data: {
treeId,
partner1PersonId: firstPartner,
partner2PersonId: secondPartner,
startDate: isoDateToDbDate(marriageDateIso),
...unionExtras,
},
})
unionId = union.id
unionByPair.set(pairKey(firstPartner, secondPartner), unionId)
unionsCreated++
if (familyEvents.length) {
await tx.lifeEvent.createMany({ data: familyEvents.map((event, index) => ({ ...event, treeId, unionId: union.id, displayOrder: index + 1 })) })
eventsCreated += familyEvents.length
}
}
const chilNodes = findAllChildren(fam, 'CHIL')
@@ -643,6 +659,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
parentPersonId: parentId,
childPersonId: childId,
viaUnionId: unionId,
...(adoptedByChild.get(chilXref)?.has(fam.xref!) ? { parentageType: 'adoption' } : {}),
displayOrder: await nextSiblingOrder(tx, treeId, parentId),
},
})
@@ -660,7 +677,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
action: 'gedcom_imported',
entityType: 'tree',
entityId: treeId,
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
},
})
}, { timeout: 60000 })
@@ -670,6 +687,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
// 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 })
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs, mediaRefs })
})
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { adoptedFamilies, familyExtras, personExtras } from '../src/lib/gedcom-events'
import { indexGedcomRecords } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
const text = [
'0 @I1@ INDI',
'1 NAME Louis /RAGOT/',
'2 NICK Loulou',
'1 BIRT',
'2 DATE ABT 1879',
'2 PLAC La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France,',
'2 SOUR @S1@',
'3 PAGE Acte n° 12',
'1 BAPM',
'2 DATE 9 MAR 1879',
'2 PLAC La Roche-sur-Yon,85191,Vendée,,France,',
'1 OCCU Instituteur',
'2 DATE FROM 1900 TO 1935',
'2 PLAC Limoges',
'1 OCCU Maire',
'2 DATE 1936',
'1 RESI',
'2 ADDR 12 rue des Écoles',
'3 CITY Limoges',
'1 EVEN',
'2 TYPE Militaire',
'2 DATE BET 1914 AND 1918',
'2 NOTE Blessé à Verdun.',
'1 DEAT',
'2 DATE @#DFRENCH R@ 18 BRUM 8',
'2 PLAC Nantes,44109,Loire-Atlantique,Pays de la Loire,France,',
'1 BURI',
'2 PLAC Cimetière de Miséricorde, Nantes',
'1 FAMC @F0@',
'2 PEDI adopted',
'1 FAMC @F9@',
'0 @F1@ FAM',
'1 HUSB @I1@',
'1 MARR',
'2 DATE EST 1905',
'2 PLAC Limoges,87085,Haute-Vienne,Nouvelle-Aquitaine,France,',
'2 SOUR Registre des mariages de Limoges',
'1 DIV Y',
'0 @S1@ SOUR',
'1 TITL Registre des naissances',
].join('\n')
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const indi = roots.find((n) => n.tag === 'INDI')!
const fam = roots.find((n) => n.tag === 'FAM')!
describe('événements de vie d’une personne', () => {
const extras = personExtras(indi as never, records)
const byType = (type: string) => extras.events.filter((e) => e.type === type)
it('précision des dates, lieu de décès, surnom', () => {
expect(extras.birthDatePrecision).toBe('about')
expect(extras.deathDatePrecision).toBeNull()
expect(extras.deathPlace).toBe('Nantes, Loire-Atlantique, Pays de la Loire, France')
expect(extras.nickname).toBe('Loulou')
})
it('naissance : seulement ses sources, sans recopier la date ni le lieu', () => {
expect(byType('birth')).toEqual([expect.objectContaining({ sources: 'Registre des naissances, Acte n° 12', date: null, place: null })])
})
it('baptême, métiers successifs, résidence, inhumation', () => {
expect(byType('baptism')[0]).toMatchObject({ place: 'La Roche-sur-Yon, Vendée, France', datePrecision: null })
expect(byType('baptism')[0].date?.toISOString().slice(0, 10)).toBe('1879-03-09')
expect(byType('occupation').map((e) => e.description)).toEqual(['Instituteur', 'Maire'])
expect(byType('occupation')[0]).toMatchObject({ datePrecision: 'from', dateText: 'FROM 1900 TO 1935', place: 'Limoges' })
expect(byType('occupation')[0].dateEnd?.getUTCFullYear()).toBe(1935)
expect(byType('residence')[0].place).toBe('12 rue des Écoles, Limoges')
expect(byType('burial')[0].place).toBe('Cimetière de Miséricorde, Nantes')
})
it('événement libre (Heredis « Militaire ») avec sa note', () => {
expect(byType('custom')[0]).toMatchObject({ label: 'Militaire', datePrecision: 'between', notes: 'Blessé à Verdun.' })
})
it('adoption déclarée par PEDI', () => {
expect([...adoptedFamilies(indi as never)]).toEqual(['F0'])
})
})
describe('événements d’un couple', () => {
const extras = familyExtras(fam as never, records)
it('lieu et précision du mariage, sources du mariage, divorce', () => {
expect(extras.startPlace).toBe('Limoges, Haute-Vienne, Nouvelle-Aquitaine, France')
expect(extras.startDatePrecision).toBe('estimated')
expect(extras.events.map((e) => e.type)).toEqual(['marriage', 'divorce'])
expect(extras.events[0]).toMatchObject({ sources: 'Registre des mariages de Limoges', place: null })
})
})