mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 08:22:03 +00:00
Import GEDCOM : vraies dates, toutes les notes
Dates : « vers », « avant », « après », « entre … et … », dates juliennes, calendrier républicain converti en grégorien, doubles années (1700/01). « BET 1794 AND 1796 » ou « @#DFRENCH R@ 18 BRUM 8 » étaient perdues, « ABT 1900 » devient au moins 1900. La précision est déjà lue pour la suite. Notes : notes partagées (« 1 NOTE @N1@ »), plusieurs notes, RTF nettoyé. Le lecteur garde la valeur brute de chaque ligne : une note coupée en CONC se recolle avec ses espaces (« pendant trente-cinq ans », plus « pendanttrente »). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d35bddf27
commit
dd738c483a
5 files changed
+247
-40
No files matched your search
@@ -0,0 +1,108 @@
|
||||
// Dates GEDCOM : « 15 JAN 1900 », mais aussi « ABT 1900 » (vers), « BEF », « AFT », « BET 1794 AND 1796 »,
|
||||
// « FROM … TO … », « INT 1900 (texte) », les doubles années « 12 FEB 1700/01 », le calendrier julien
|
||||
// (« @#DJULIAN@ 1563 ») et le calendrier républicain (« @#DFRENCH R@ 18 BRUM 8 », converti en grégorien).
|
||||
// On renvoie une date ISO (année, année-mois ou jour), sa précision et le texte d'origine.
|
||||
|
||||
export type GedcomDatePrecision = 'exact' | 'about' | 'estimated' | 'calculated' | 'before' | 'after' | 'between' | 'from' | 'to' | 'interpreted'
|
||||
|
||||
export type ParsedGedcomDate = {
|
||||
iso: string
|
||||
precision: GedcomDatePrecision
|
||||
endIso: string | null
|
||||
text: string
|
||||
}
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
|
||||
}
|
||||
|
||||
// Calendrier républicain : début de chaque année (an I à XIV), années sextiles III, VII et XI
|
||||
const FRENCH_YEAR_START: Record<number, string> = {
|
||||
1: '1792-09-22', 2: '1793-09-22', 3: '1794-09-22', 4: '1795-09-23', 5: '1796-09-22', 6: '1797-09-22',
|
||||
7: '1798-09-22', 8: '1799-09-23', 9: '1800-09-23', 10: '1801-09-23', 11: '1802-09-23', 12: '1803-09-24',
|
||||
13: '1804-09-23', 14: '1805-09-23',
|
||||
}
|
||||
const FRENCH_MONTHS: Record<string, number> = {
|
||||
VEND: 1, BRUM: 2, FRIM: 3, NIVO: 4, PLUV: 5, VENT: 6, GERM: 7, FLOR: 8, PRAI: 9, MESS: 10, THER: 11, FRUC: 12, COMP: 13,
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
function frenchToIso(day: number | null, month: number | null, year: number): string | null {
|
||||
const start = FRENCH_YEAR_START[year]
|
||||
if (!start) return null
|
||||
if (!month) return start.slice(0, 4)
|
||||
const offset = (month - 1) * 30 + ((day ?? 1) - 1)
|
||||
const date = new Date(`${start}T00:00:00Z`)
|
||||
date.setUTCDate(date.getUTCDate() + offset)
|
||||
const iso = date.toISOString().slice(0, 10)
|
||||
return day ? iso : iso.slice(0, 7)
|
||||
}
|
||||
|
||||
// Une date simple, après le qualificatif : jour, mois, année, avec son calendrier
|
||||
function parseSimple(raw: string): string | null {
|
||||
let text = raw.trim().toUpperCase()
|
||||
let calendar = 'GREGORIAN'
|
||||
const escape = text.match(/^@#D([A-Z ]+?)@\s*/)
|
||||
if (escape) {
|
||||
calendar = escape[1].trim()
|
||||
text = text.slice(escape[0].length)
|
||||
}
|
||||
text = text.replace(/\s+B\.?C\.?$/, '').trim()
|
||||
const parts = text.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0 || parts.length > 3) return null
|
||||
|
||||
if (calendar === 'FRENCH R') {
|
||||
const year = Number(parts[parts.length - 1])
|
||||
const month = parts.length >= 2 ? FRENCH_MONTHS[parts[parts.length - 2]] ?? null : null
|
||||
const day = parts.length === 3 ? Number(parts[0]) : null
|
||||
if (!Number.isInteger(year) || (parts.length >= 2 && !month) || (day !== null && !(day >= 1 && day <= 30))) return null
|
||||
return frenchToIso(day, month, year)
|
||||
}
|
||||
if (calendar !== 'GREGORIAN' && calendar !== 'JULIAN') return null
|
||||
|
||||
// Double année : « 1700/01 » = 1701 en style moderne
|
||||
const yearMatch = parts[parts.length - 1].match(/^(\d{3,4})(?:\/(\d{1,2}))?$/)
|
||||
if (!yearMatch) return null
|
||||
let year = Number(yearMatch[1])
|
||||
if (yearMatch[2]) {
|
||||
const suffix = yearMatch[2]
|
||||
year = Number(String(year).slice(0, String(year).length - suffix.length) + suffix)
|
||||
if (year < Number(yearMatch[1])) year += 10 ** suffix.length
|
||||
}
|
||||
const yearText = String(year).padStart(4, '0')
|
||||
if (parts.length === 1) return yearText
|
||||
const month = MONTHS[parts[parts.length - 2]]
|
||||
if (!month) return null
|
||||
if (parts.length === 2) return `${yearText}-${pad(month)}`
|
||||
const day = Number(parts[0])
|
||||
if (!(day >= 1 && day <= 31)) return null
|
||||
return `${yearText}-${pad(month)}-${pad(day)}`
|
||||
}
|
||||
|
||||
export function parseGedcomDate(value: string | null | undefined): ParsedGedcomDate | null {
|
||||
const text = (value ?? '').trim()
|
||||
if (!text) return null
|
||||
const upper = text.toUpperCase()
|
||||
|
||||
const range = upper.match(/^BET\s+(.+?)\s+AND\s+(.+)$/) ?? upper.match(/^FROM\s+(.+?)\s+TO\s+(.+)$/)
|
||||
if (range) {
|
||||
const iso = parseSimple(range[1])
|
||||
const endIso = parseSimple(range[2])
|
||||
if (!iso && !endIso) return null
|
||||
return { iso: (iso ?? endIso)!, endIso: iso ? endIso : null, precision: upper.startsWith('BET') ? 'between' : 'from', text }
|
||||
}
|
||||
|
||||
const qualifiers: Record<string, GedcomDatePrecision> = {
|
||||
ABT: 'about', EST: 'estimated', CAL: 'calculated', BEF: 'before', AFT: 'after', FROM: 'from', TO: 'to', INT: 'interpreted',
|
||||
}
|
||||
const qualified = upper.match(/^(ABT|EST|CAL|BEF|AFT|FROM|TO|INT)\.?\s+(.+)$/)
|
||||
if (qualified) {
|
||||
const rest = qualified[1] === 'INT' ? qualified[2].replace(/\s*\(.*\)\s*$/, '') : qualified[2]
|
||||
const iso = parseSimple(rest)
|
||||
return iso ? { iso, endIso: null, precision: qualifiers[qualified[1]], text } : null
|
||||
}
|
||||
|
||||
const iso = parseSimple(upper.replace(/\s*\(.*\)\s*$/, ''))
|
||||
return iso ? { iso, endIso: null, precision: 'exact', text } : null
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type GedcomPhotoNode = {
|
||||
xref?: string
|
||||
tag: string
|
||||
value: string
|
||||
rawValue?: string
|
||||
children: GedcomPhotoNode[]
|
||||
}
|
||||
|
||||
@@ -124,10 +125,10 @@ export function localMediaPath(value: string): string | null {
|
||||
|
||||
function joinedText(node: GedcomPhotoNode | undefined): string {
|
||||
if (!node) return ''
|
||||
let text = node.value
|
||||
let text = node.rawValue ?? node.value
|
||||
for (const c of node.children) {
|
||||
if (c.tag === 'CONT') text += `\n${c.value}`
|
||||
else if (c.tag === 'CONC') text += c.value
|
||||
if (c.tag === 'CONT') text += `\n${c.rawValue ?? c.value}`
|
||||
else if (c.tag === 'CONC') text += c.rawValue ?? c.value
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
|
||||
import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson } from '../lib/gedcom-photos.js'
|
||||
import { parseGedcomDate } from '../lib/gedcom-dates.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'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
|
||||
@@ -69,6 +71,9 @@ type GedcomNode = {
|
||||
xref?: string
|
||||
tag: string
|
||||
value: string
|
||||
// Valeur telle qu'écrite, espaces compris : une ligne coupée en CONC se recolle sans rien perdre
|
||||
// (« pendant » + « 2 CONC trente-cinq ans » ou « pendant » + « 2 CONC trente-cinq ans »)
|
||||
rawValue?: string
|
||||
children: GedcomNode[]
|
||||
}
|
||||
|
||||
@@ -79,20 +84,21 @@ export function parseGedcom(text: string): GedcomNode[] {
|
||||
let nodeCount = 0
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.length > MAX_LINE_LENGTH) continue
|
||||
const line = raw.replace(/^[\s]+/, '')
|
||||
if (!line.trim() || line.length > MAX_LINE_LENGTH) continue
|
||||
nodeCount += 1
|
||||
if (nodeCount > MAX_GEDCOM_NODES) throw new GedcomTooLargeError()
|
||||
|
||||
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/)
|
||||
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:[ \t](.*))?$/)
|
||||
if (!match) continue
|
||||
|
||||
const level = parseInt(match[1], 10)
|
||||
const xref = match[2] ? match[2].slice(1, -1) : undefined
|
||||
const tag = match[3]
|
||||
const value = match[4]?.trim() ?? ''
|
||||
const rawValue = match[4] ?? ''
|
||||
const value = rawValue.trim()
|
||||
|
||||
const node: GedcomNode = { level, xref, tag, value, children: [] }
|
||||
const node: GedcomNode = { level, xref, tag, value, rawValue, children: [] }
|
||||
|
||||
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
||||
stack.pop()
|
||||
@@ -120,36 +126,30 @@ function findAllChildren(node: GedcomNode | undefined, tag: string): GedcomNode[
|
||||
|
||||
// ─── Date Conversion ────────────────────────────────────────────────────────
|
||||
|
||||
const MONTH_MAP: Record<string, string> = {
|
||||
JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06',
|
||||
JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12',
|
||||
}
|
||||
|
||||
const MONTHS_ARR = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
|
||||
// Lecture complète dans lib/gedcom-dates.ts : vers, avant, après, entre, julien, républicain, double année
|
||||
function gedcomDateToIso(value: string): string | null {
|
||||
if (!value) return null
|
||||
// Remove qualifiers (ABT, BEF, AFT, EST, CAL, etc.)
|
||||
const cleaned = value.trim().toUpperCase().replace(/^(ABT|BEF|AFT|EST|CAL|INT|FROM|TO|BET|AND)\s+/, '')
|
||||
return parseGedcomDate(value)?.iso ?? null
|
||||
}
|
||||
|
||||
// "15 JAN 1900" → "1900-01-15"
|
||||
const ddMmmYyyy = cleaned.match(/^(\d{1,2})\s+([A-Z]{3})\s+(\d{4})$/)
|
||||
if (ddMmmYyyy) {
|
||||
const month = MONTH_MAP[ddMmmYyyy[2]]
|
||||
if (month) return `${ddMmmYyyy[3]}-${month}-${ddMmmYyyy[1].padStart(2, '0')}`
|
||||
// Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC),
|
||||
// RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide.
|
||||
export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null {
|
||||
const texts: string[] = []
|
||||
for (const note of findAllChildren(indi, 'NOTE')) {
|
||||
const pointer = note.value.match(/^@([^@]+)@$/)?.[1]
|
||||
const source = pointer ? records.note.get(pointer) : note
|
||||
if (!source) continue
|
||||
let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value)
|
||||
for (const part of source.children) {
|
||||
if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}`
|
||||
else if (part.tag === 'CONC') text += part.rawValue ?? part.value
|
||||
}
|
||||
const clean = plainNoteText(text)
|
||||
if (clean) texts.push(clean)
|
||||
}
|
||||
|
||||
// "JAN 1900" → "1900-01"
|
||||
const mmmYyyy = cleaned.match(/^([A-Z]{3})\s+(\d{4})$/)
|
||||
if (mmmYyyy) {
|
||||
const month = MONTH_MAP[mmmYyyy[1]]
|
||||
if (month) return `${mmmYyyy[2]}-${month}`
|
||||
}
|
||||
|
||||
// "1900" → "1900"
|
||||
if (/^\d{4}$/.test(cleaned)) return cleaned
|
||||
|
||||
return null
|
||||
return texts.length ? texts.join('\n\n') : null
|
||||
}
|
||||
|
||||
function isoDateToDbDate(iso: string | null): Date | null {
|
||||
@@ -550,12 +550,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200)
|
||||
|
||||
const noteNode = findChild(indi, 'NOTE')
|
||||
let notes: string | null = null
|
||||
if (noteNode?.value) {
|
||||
const contLines = findAllChildren(noteNode, 'CONT').map((n) => n.value)
|
||||
notes = clip([noteNode.value, ...contLines].join('\n'), 2000)
|
||||
}
|
||||
const notes = clip(personNotes(indi, records), 2000)
|
||||
|
||||
const personFirstName = (firstName || '?').slice(0, 120)
|
||||
const personLastName = (lastName || '').slice(0, 120)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseGedcomDate } from '../src/lib/gedcom-dates'
|
||||
|
||||
const iso = (value: string) => parseGedcomDate(value)?.iso ?? null
|
||||
|
||||
describe('dates GEDCOM', () => {
|
||||
it('dates simples', () => {
|
||||
expect(iso('15 JAN 1900')).toBe('1900-01-15')
|
||||
expect(iso('JAN 1900')).toBe('1900-01')
|
||||
expect(iso('1900')).toBe('1900')
|
||||
expect(iso('856')).toBe('0856')
|
||||
expect(iso('3 mar 1879')).toBe('1879-03-03')
|
||||
})
|
||||
|
||||
it('précision gardée', () => {
|
||||
expect(parseGedcomDate('ABT 1900')).toMatchObject({ iso: '1900', precision: 'about' })
|
||||
expect(parseGedcomDate('BEF 12 MAY 1920')).toMatchObject({ iso: '1920-05-12', precision: 'before' })
|
||||
expect(parseGedcomDate('AFT 1850')).toMatchObject({ iso: '1850', precision: 'after' })
|
||||
expect(parseGedcomDate('EST 1700')).toMatchObject({ precision: 'estimated' })
|
||||
expect(parseGedcomDate('CAL 1801')).toMatchObject({ precision: 'calculated' })
|
||||
})
|
||||
|
||||
it('intervalles', () => {
|
||||
expect(parseGedcomDate('BET 1794 AND 1796')).toEqual({ iso: '1794', endIso: '1796', precision: 'between', text: 'BET 1794 AND 1796' })
|
||||
expect(parseGedcomDate('FROM 1914 TO 1918')).toMatchObject({ iso: '1914', endIso: '1918', precision: 'from' })
|
||||
})
|
||||
|
||||
it('date interprétée et texte entre parenthèses', () => {
|
||||
expect(parseGedcomDate('INT 1900 (vers la fin du siècle)')).toMatchObject({ iso: '1900', precision: 'interpreted' })
|
||||
expect(iso('(pendant la guerre)')).toBeNull()
|
||||
})
|
||||
|
||||
it('double année', () => {
|
||||
expect(iso('12 FEB 1700/01')).toBe('1701-02-12')
|
||||
expect(iso('1699/00')).toBe('1700')
|
||||
})
|
||||
|
||||
it('calendrier julien : la date reste celle écrite', () => {
|
||||
expect(iso('@#DJULIAN@ 1563')).toBe('1563')
|
||||
expect(iso('@#DJULIAN@ 3 SEP 1599')).toBe('1599-09-03')
|
||||
})
|
||||
|
||||
it('calendrier républicain converti', () => {
|
||||
expect(iso('@#DFRENCH R@ 18 BRUM 8')).toBe('1799-11-09')
|
||||
expect(iso('@#DFRENCH R@ 1 VEND 1')).toBe('1792-09-22')
|
||||
expect(iso('@#DFRENCH R@ 9 THER 2')).toBe('1794-07-27')
|
||||
expect(iso('@#DFRENCH R@ GERM 3')).toBe('1795-03')
|
||||
expect(iso('@#DFRENCH R@ 5')).toBe('1796')
|
||||
})
|
||||
|
||||
it('illisible : rien', () => {
|
||||
expect(iso('')).toBeNull()
|
||||
expect(iso('vers 1900')).toBeNull()
|
||||
expect(iso('@#DHEBREW@ 5660')).toBeNull()
|
||||
expect(iso('32 JAN 1900')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { indexGedcomRecords } from '../src/lib/gedcom-photos'
|
||||
import { parseGedcom, personNotes } from '../src/routes/gedcom'
|
||||
|
||||
function notesOf(text: string) {
|
||||
const roots = parseGedcom(text)
|
||||
const records = indexGedcomRecords(roots)
|
||||
const indi = roots.find((n) => n.tag === 'INDI')!
|
||||
return personNotes(indi as never, records)
|
||||
}
|
||||
|
||||
describe('notes d’une personne', () => {
|
||||
it('note écrite sous la personne, sur plusieurs lignes (CONT) et coupée (CONC)', () => {
|
||||
expect(notesOf([
|
||||
'0 @I1@ INDI',
|
||||
'1 NOTE Instituteur à Limoges pendant',
|
||||
'2 CONC trente-cinq ans.',
|
||||
'2 CONT Il aimait la pêche.',
|
||||
].join('\n'))).toBe('Instituteur à Limoges pendant trente-cinq ans.\nIl aimait la pêche.')
|
||||
})
|
||||
|
||||
it('note partagée par renvoi (Gramps, Geneatique, Ancestry)', () => {
|
||||
expect(notesOf([
|
||||
'0 @I1@ INDI',
|
||||
'1 NOTE @N7@',
|
||||
'0 @N7@ NOTE Revenu du front en 1919.',
|
||||
'1 CONT Blessé à Verdun.',
|
||||
].join('\n'))).toBe('Revenu du front en 1919.\nBlessé à Verdun.')
|
||||
})
|
||||
|
||||
it('plusieurs notes, séparées par une ligne vide', () => {
|
||||
expect(notesOf([
|
||||
'0 @I1@ INDI',
|
||||
'1 NOTE Première note.',
|
||||
'1 NOTE @N1@',
|
||||
'0 @N1@ NOTE Deuxième note.',
|
||||
].join('\n'))).toBe('Première note.\n\nDeuxième note.')
|
||||
})
|
||||
|
||||
it('aucune note, ou renvoi cassé', () => {
|
||||
expect(notesOf('0 @I1@ INDI\n1 NAME Jean /Dupont/')).toBeNull()
|
||||
expect(notesOf('0 @I1@ INDI\n1 NOTE @N404@')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user