mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 08:16:09 +00:00
É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:
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
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user