diff --git a/backend/prisma/migrations/20261016_001_evenements_de_vie/migration.sql b/backend/prisma/migrations/20261016_001_evenements_de_vie/migration.sql new file mode 100644 index 0000000..262c572 --- /dev/null +++ b/backend/prisma/migrations/20261016_001_evenements_de_vie/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3176006..43660a1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/src/lib/gedcom-events.ts b/backend/src/lib/gedcom-events.ts new file mode 100644 index 0000000..c5cf8fe --- /dev/null +++ b/backend/src/lib/gedcom-events.ts @@ -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 = { + 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 = { + 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 { + const out = new Set() + 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 +} diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index efb96af..d790a82 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -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) diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 68f5842..cb73a6d 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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 }) }) } diff --git a/backend/test/gedcom-events.test.ts b/backend/test/gedcom-events.test.ts new file mode 100644 index 0000000..0d9f11a --- /dev/null +++ b/backend/test/gedcom-events.test.ts @@ -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 }) + }) +})