Étapes 6-9 + types de lieux + picker + filtres
- Étape 6 : formulaire de saisie dynamique des relevés (piloté par source_type_fields, calendriers grégorien/julien/républicain) - Étape 7 : workflow de statut des sources + notifications mail+DB (SourceAValider, SourceRejetee) - Étape 8 : recherche fulltext PostgreSQL avec filtres type/lieu/années et CTE récursive pour les subdivisions de lieux - Étape 9 : export GEDCOM 5.5.1 (GedcomExportService + DateConversionService) - Types de lieux : CRUD admin (LieuTypeController) avec champ ordre - Composant lieu-picker : modale Alpine.js avec recherche AJAX + debounce - Filtres sources : statut, type, lieu (CTE récursive), période annee_debut/annee_fin - Filtres lieux : type, texte, lieu parent avec descendants (CTE récursive) - Migration : lieu_id + annee_debut + annee_fin sur sources Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTime;
|
||||
|
||||
class DateConversionService
|
||||
{
|
||||
// Dates de début du 1er Vendémiaire pour chaque An républicain
|
||||
private const YEAR_STARTS = [
|
||||
1 => '1792-09-22',
|
||||
2 => '1793-09-22',
|
||||
3 => '1794-09-23',
|
||||
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-23',
|
||||
13 => '1804-09-23',
|
||||
14 => '1805-09-23',
|
||||
];
|
||||
|
||||
private const MONTHS = [
|
||||
'vendémiaire' => 1, 'vendemiaire' => 1,
|
||||
'brumaire' => 2,
|
||||
'frimaire' => 3,
|
||||
'nivôse' => 4, 'nivose' => 4,
|
||||
'pluviôse' => 5, 'pluviose' => 5,
|
||||
'ventôse' => 6, 'ventose' => 6,
|
||||
'germinal' => 7,
|
||||
'floréal' => 8, 'floreal' => 8,
|
||||
'prairial' => 9,
|
||||
'messidor' => 10,
|
||||
'thermidor' => 11,
|
||||
'fructidor' => 12,
|
||||
];
|
||||
|
||||
private const ROMAN = [
|
||||
'XIV' => 14, 'XIII' => 13, 'XII' => 12, 'XI' => 11, 'X' => 10,
|
||||
'IX' => 9, 'VIII' => 8, 'VII' => 7, 'VI' => 6, 'V' => 5,
|
||||
'IV' => 4, 'III' => 3, 'II' => 2, 'I' => 1,
|
||||
];
|
||||
|
||||
private const GEDCOM_MONTHS = [
|
||||
1 => 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR',
|
||||
5 => 'MAY', 6 => 'JUN', 7 => 'JUL', 8 => 'AUG',
|
||||
9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convertit un champ date JSONB { valeur, calendrier } en chaîne GEDCOM.
|
||||
* Retourne null si la conversion échoue.
|
||||
*/
|
||||
public function toGedcomDate(?array $dateField): ?string
|
||||
{
|
||||
if (empty($dateField['valeur'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$valeur = trim($dateField['valeur']);
|
||||
$calendrier = $dateField['calendrier'] ?? 'gregorien';
|
||||
|
||||
return match ($calendrier) {
|
||||
'gregorien' => $this->gregorianToGedcom($valeur),
|
||||
'julien' => $this->julianToGedcom($valeur),
|
||||
'republicain' => $this->republicanToGedcom($valeur),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD → "D MON YYYY" */
|
||||
public function gregorianToGedcom(string $date): ?string
|
||||
{
|
||||
$d = DateTime::createFromFormat('Y-m-d', $date);
|
||||
if (! $d) {
|
||||
return null;
|
||||
}
|
||||
$day = (int) $d->format('j');
|
||||
$month = self::GEDCOM_MONTHS[(int) $d->format('n')];
|
||||
$year = $d->format('Y');
|
||||
return "{$day} {$month} {$year}";
|
||||
}
|
||||
|
||||
/** Julien : même format YYYY-MM-DD, marqué @#DJULIAN@ */
|
||||
private function julianToGedcom(string $date): ?string
|
||||
{
|
||||
$g = $this->gregorianToGedcom($date);
|
||||
return $g ? "@#DJULIAN@ {$g}" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "15 Vendémiaire An III" → date grégorienne GEDCOM.
|
||||
* Retourne la date avec préfixe @#DFRENCH R@ (standard GEDCOM pour calendrier républicain).
|
||||
*/
|
||||
private function republicanToGedcom(string $date): ?string
|
||||
{
|
||||
$gregory = $this->republicanToGregorian($date);
|
||||
if ($gregory) {
|
||||
return $this->gregorianToGedcom($gregory);
|
||||
}
|
||||
|
||||
// Fallback : on conserve la date telle quelle dans un format lisible
|
||||
return "({$date})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit "15 Vendémiaire An III" → "1794-10-06".
|
||||
*/
|
||||
public function republicanToGregorian(string $input): ?string
|
||||
{
|
||||
// Normaliser l'entrée
|
||||
$input = trim($input);
|
||||
|
||||
// Pattern : "DD NomDuMois An N" ou "DD NomDuMois An XIV"
|
||||
$pattern = '/^(\d{1,2})\s+([\wéèêôûî]+)\s+[Aa]n\s+([IVXLCDM\d]+)$/iu';
|
||||
|
||||
if (! preg_match($pattern, $input, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) $m[1];
|
||||
$monthStr = mb_strtolower(trim($m[2]));
|
||||
$yearStr = strtoupper(trim($m[3]));
|
||||
|
||||
// Résoudre le mois
|
||||
$monthNum = self::MONTHS[$monthStr] ?? null;
|
||||
if (! $monthNum || $day < 1 || $day > 30) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Résoudre l'année (chiffres arabes ou romains)
|
||||
$yearNum = is_numeric($yearStr)
|
||||
? (int) $yearStr
|
||||
: (self::ROMAN[$yearStr] ?? null);
|
||||
|
||||
if (! $yearNum || ! isset(self::YEAR_STARTS[$yearNum])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculer le nombre de jours depuis le 1er Vendémiaire
|
||||
$daysOffset = ($monthNum - 1) * 30 + ($day - 1);
|
||||
|
||||
$start = new DateTime(self::YEAR_STARTS[$yearNum]);
|
||||
$start->modify("+{$daysOffset} days");
|
||||
|
||||
return $start->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Releve;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GedcomExportService
|
||||
{
|
||||
// Mapping des noms de champs vers les tags GEDCOM
|
||||
private const FIELD_EVENT_MAP = [
|
||||
'naissance' => 'BIRT', 'birth' => 'BIRT',
|
||||
'mariage' => 'MARR', 'marriage' => 'MARR',
|
||||
'deces' => 'DEAT', 'décès' => 'DEAT', 'death' => 'DEAT',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DateConversionService $dates,
|
||||
) {}
|
||||
|
||||
public function exportSource(Source $source): string
|
||||
{
|
||||
$source->load('sourceType.fields', 'releves.createur');
|
||||
return $this->buildGedcom($source->releves, $source->nom, $source->sourceType->nom);
|
||||
}
|
||||
|
||||
public function exportReleves(Collection $releves, string $titre = 'Export'): string
|
||||
{
|
||||
return $this->buildGedcom($releves, $titre);
|
||||
}
|
||||
|
||||
private function buildGedcom(Collection $releves, string $sourceName, string $sourceTypeName = ''): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = $this->header($sourceName);
|
||||
|
||||
$sourceTag = '@S1@';
|
||||
$famId = 0;
|
||||
$indiId = 0;
|
||||
|
||||
// Détecter le type d'événement depuis le nom du type de source
|
||||
$eventType = $this->detectEventType($sourceTypeName ?: $sourceName);
|
||||
|
||||
$individuals = [];
|
||||
$families = [];
|
||||
|
||||
foreach ($releves as $releve) {
|
||||
$data = $releve->data ?? [];
|
||||
|
||||
$indiId++;
|
||||
$indiTag = "@I{$indiId}@";
|
||||
|
||||
$nom = $data['nom'] ?? '';
|
||||
$prenom = $data['prenom'] ?? $data['prenom_epoux'] ?? $data['prenom_epouse'] ?? '';
|
||||
|
||||
// ── Individu principal ──────────────────────────────────────────
|
||||
$indi = [];
|
||||
$indi[] = "0 {$indiTag} INDI";
|
||||
if ($nom || $prenom) {
|
||||
$indi[] = "1 NAME {$prenom} /{$nom}/";
|
||||
if ($prenom) $indi[] = "1 GIVN {$prenom}";
|
||||
if ($nom) $indi[] = "1 SURN {$nom}";
|
||||
}
|
||||
|
||||
// Événement principal
|
||||
$dateField = $data['date_evenement'] ?? $data['date_naissance'] ?? $data['date_mariage'] ?? null;
|
||||
$gedDate = is_array($dateField) ? $this->dates->toGedcomDate($dateField) : null;
|
||||
$lieu = $data['lieu_naissance'] ?? $data['lieu_evenement'] ?? $data['lieu_mariage'] ?? null;
|
||||
|
||||
$indi[] = "1 {$eventType}";
|
||||
if ($gedDate) $indi[] = "2 DATE {$gedDate}";
|
||||
if ($lieu) $indi[] = "2 PLAC {$lieu}";
|
||||
|
||||
// Référence à la source
|
||||
$indi[] = "1 SOUR {$sourceTag}";
|
||||
if (isset($data['numero_acte'])) {
|
||||
$indi[] = "2 PAGE Acte n°{$data['numero_acte']}";
|
||||
}
|
||||
|
||||
// Notes (champs non mappés)
|
||||
$noteFields = ['note', 'observation', 'remarque'];
|
||||
foreach ($noteFields as $nf) {
|
||||
if (!empty($data[$nf])) {
|
||||
foreach ($this->wrapNote($data[$nf]) as $noteLine) {
|
||||
$indi[] = $noteLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$individuals[] = implode("\n", $indi);
|
||||
|
||||
// ── Famille (père/mère ou époux/épouse) ─────────────────────────
|
||||
$hasFather = !empty($data['nom_pere']) || !empty($data['prenom_pere']);
|
||||
$hasMother = !empty($data['nom_mere']) || !empty($data['prenom_mere']);
|
||||
$isMarriage = $eventType === 'MARR';
|
||||
|
||||
if ($hasFather || $hasMother || $isMarriage) {
|
||||
$famId++;
|
||||
$famTag = "@F{$famId}@";
|
||||
|
||||
$fam = [];
|
||||
$fam[] = "0 {$famTag} FAM";
|
||||
|
||||
if ($isMarriage) {
|
||||
// Pour un mariage, créer époux et épouse séparément
|
||||
if (!empty($data['nom_epoux']) || !empty($data['prenom_epoux'])) {
|
||||
$indiId++;
|
||||
$epouxTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$epouxTag} INDI",
|
||||
"1 NAME {$data['prenom_epoux']} /{$data['nom_epoux']}/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 HUSB {$epouxTag}";
|
||||
}
|
||||
if (!empty($data['nom_epouse']) || !empty($data['prenom_epouse'])) {
|
||||
$indiId++;
|
||||
$epouseTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$epouseTag} INDI",
|
||||
"1 NAME {$data['prenom_epouse']} /{$data['nom_epouse']}/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 WIFE {$epouseTag}";
|
||||
}
|
||||
$fam[] = "1 MARR";
|
||||
if ($gedDate) $fam[] = "2 DATE {$gedDate}";
|
||||
if ($lieu) $fam[] = "2 PLAC {$lieu}";
|
||||
// Lier l'individu principal comme enfant si besoin
|
||||
} else {
|
||||
// Naissance/décès : père et mère
|
||||
if ($hasFather) {
|
||||
$indiId++;
|
||||
$pereTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$pereTag} INDI",
|
||||
"1 NAME " . ($data['prenom_pere'] ?? '') . " /" . ($data['nom_pere'] ?? '') . "/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 HUSB {$pereTag}";
|
||||
}
|
||||
if ($hasMother) {
|
||||
$indiId++;
|
||||
$mereTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$mereTag} INDI",
|
||||
"1 NAME " . ($data['prenom_mere'] ?? '') . " /" . ($data['nom_mere'] ?? '') . "/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 WIFE {$mereTag}";
|
||||
}
|
||||
$fam[] = "1 CHIL {$indiTag}";
|
||||
// Lier l'enfant à sa famille
|
||||
$indi[] = "1 FAMC {$famTag}";
|
||||
}
|
||||
|
||||
$families[] = implode("\n", $fam);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($individuals as $indi) {
|
||||
$lines[] = $indi;
|
||||
}
|
||||
foreach ($families as $fam) {
|
||||
$lines[] = $fam;
|
||||
}
|
||||
|
||||
// Enregistrement SOURCE
|
||||
$lines[] = implode("\n", [
|
||||
"0 {$sourceTag} SOUR",
|
||||
"1 TITL {$sourceName}",
|
||||
"1 AUTH MesRelevés",
|
||||
]);
|
||||
|
||||
$lines[] = "0 TRLR";
|
||||
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
private function header(string $sourceName): string
|
||||
{
|
||||
$date = now()->format('d M Y');
|
||||
$time = now()->format('H:i:s');
|
||||
|
||||
return implode("\n", [
|
||||
'0 HEAD',
|
||||
'1 SOUR MESRELEVES',
|
||||
'2 NAME MesRelevés',
|
||||
'2 VERS 1.0',
|
||||
'1 DEST ANY',
|
||||
"1 DATE {$date}",
|
||||
"2 TIME {$time}",
|
||||
'1 GEDC',
|
||||
'2 VERS 5.5.1',
|
||||
'2 FORM LINEAGE-LINKED',
|
||||
'1 CHAR UTF-8',
|
||||
"1 FILE {$sourceName}.ged",
|
||||
'1 LANG French',
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectEventType(string $name): string
|
||||
{
|
||||
$lower = mb_strtolower($name);
|
||||
foreach (self::FIELD_EVENT_MAP as $keyword => $tag) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
return 'EVEN';
|
||||
}
|
||||
|
||||
/** Découpe une note longue en lignes GEDCOM (max 248 chars par ligne) */
|
||||
private function wrapNote(string $text): array
|
||||
{
|
||||
$lines = [];
|
||||
$chunks = mb_str_split($text, 248);
|
||||
foreach ($chunks as $i => $chunk) {
|
||||
$lines[] = ($i === 0 ? '1 NOTE ' : '2 CONT ') . $chunk;
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user