Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

90
scripts/asm3-analyze.php Normal file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/app/Services/Asm3Analyzer.php';
$args = array_values(array_slice($argv, 1));
$json = false;
$output = null;
$source = null;
for ($i = 0; $i < count($args); $i++) {
if ($args[$i] === '--json') {
$json = true;
continue;
}
if ($args[$i] === '--output') {
$output = $args[++$i] ?? null;
continue;
}
if (str_starts_with($args[$i], '--')) {
fwrite(STDERR, "Option inconnue : {$args[$i]}\n");
exit(2);
}
$source = $args[$i];
}
if (!$source) {
fwrite(STDERR, "Usage : php scripts/asm3-analyze.php <dump.sql> [--json] [--output rapport.md]\n");
exit(2);
}
try {
$report = Asm3Analyzer::analyze($source);
} catch (Throwable $e) {
fwrite(STDERR, "Erreur : {$e->getMessage()}\n");
exit(1);
}
if ($json) {
$content = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$a = $report['animals'];
$content = "# Rapport de migration ASM3 → Globinours\n\n";
$content .=
"Analyse en lecture seule de `{$report['source']}` (" .
number_format($report['size_bytes'] / 1048576, 2, ',', ' ') .
" Mio). Aucune donnée Globinours na été modifiée.\n\n";
$content .= "## Synthèse animale\n\n| Indicateur | Nombre |\n|---|---:|\n";
$content .= '| Total des animaux | ' . ($a['active'] + $a['archived']) . " |\n";
foreach (
[
'Dossiers actifs dans ASM3' => 'active',
'Dossiers archivés dans ASM3' => 'archived',
'Déclarés adoptables' => 'adoptable',
'Non adoptables' => 'not_adoptable',
'Décédés' => 'deceased',
'Pucés' => 'microchip',
'Tatoués' => 'tattoo',
'Stérilisés' => 'neutered',
'Sans nom' => 'missing_name',
'Sans naissance' => 'missing_birth_date',
]
as $label => $key
) {
$content .= "| {$label} | {$a[$key]} |\n";
}
$content .=
"\n## Périmètre de migration\n\n| Domaine ASM3 | Table | Lignes | Cible Globinours | État |\n|---|---|---:|---|---|\n";
foreach ($report['domains'] as $d) {
$state =
['ready' => 'Prêt à mapper', 'planned' => 'À implémenter', 'review' => 'À étudier'][$d['status']] ??
$d['status'];
$content .= "| {$d['label']} | `{$d['table']}` | {$d['rows']} | `{$d['target']}` | {$state} |\n";
}
$content .=
"\n## Alertes\n\n" .
($report['warnings']
? implode("\n", array_map(static fn($w) => '- ' . $w, $report['warnings']))
: '- Aucune anomalie bloquante détectée.') .
"\n";
$content .= "\n## Tables non encore mappées\n\n";
foreach (array_slice($report['unmapped_tables'], 0, 30, true) as $table => $count) {
$content .= "- `{$table}` : {$count} ligne(s)\n";
}
}
if ($output) {
if (file_put_contents($output, $content) === false) {
fwrite(STDERR, "Impossible décrire {$output}\n");
exit(1);
}
fwrite(STDOUT, "Rapport écrit dans {$output}\n");
} else {
fwrite(STDOUT, $content);
}

21
scripts/asm3-clinical.php Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
foreach (['DB', 'Migrations', 'BackupService', 'AppSettings', 'Asm3Analyzer', 'Asm3ClinicalImporter'] as $s) {
require $root . '/app/Services/' . $s . '.php';
}
$path = $argv[1] ?? null;
$apply = in_array('--apply', $argv, true);
if (!$path) {
fwrite(STDERR, "Usage : php scripts/asm3-clinical.php dump.sql [--apply]\n");
exit(2);
}
if ($apply) {
fwrite(STDERR, 'Tapez IMPORTER MEDICAL : ');
if (trim((string) fgets(STDIN)) !== 'IMPORTER MEDICAL') {
exit(3);
}
}
Migrations::run($root . '/migrations');
echo json_encode(Asm3ClinicalImporter::run($path, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";

41
scripts/asm3-contacts.php Normal file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
foreach (
['DB', 'Migrations', 'BackupService', 'AppSettings', 'Asm3Analyzer', 'Asm3ContactPlanner', 'Asm3ContactImporter']
as $service
) {
require $root . '/app/Services/' . $service . '.php';
}
$source = null;
$apply = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--apply') {
$apply = true;
} elseif (str_starts_with($arg, '--')) {
fwrite(STDERR, "Option inconnue : {$arg}\n");
exit(2);
} else {
$source = $arg;
}
}
if (!$source) {
fwrite(STDERR, "Usage : php scripts/asm3-contacts.php <dump.sql> [--apply]\n");
exit(2);
}
if ($apply) {
fwrite(STDERR, 'IMPORT CONTACTS demandé. Tapez IMPORTER CONTACTS : ');
if (trim((string) fgets(STDIN)) !== 'IMPORTER CONTACTS') {
fwrite(STDERR, "Import annulé.\n");
exit(3);
}
}
try {
Migrations::run($root . '/migrations');
echo json_encode(Asm3ContactImporter::run($source, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
"\n";
} catch (Throwable $e) {
fwrite(STDERR, "Échec : {$e->getMessage()}\n");
exit(1);
}

46
scripts/asm3-import.php Normal file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
require $root . '/app/Services/DB.php';
require $root . '/app/Services/Migrations.php';
require $root . '/app/Services/BackupService.php';
require $root . '/app/Services/AppSettings.php';
require $root . '/app/Services/Asm3Analyzer.php';
require $root . '/app/Services/Asm3MigrationPlanner.php';
require $root . '/app/Services/Asm3AnimalImporter.php';
$source = null;
$apply = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--apply') {
$apply = true;
continue;
}
if (str_starts_with($arg, '--')) {
fwrite(STDERR, "Option inconnue : {$arg}\n");
exit(2);
}
$source = $arg;
}
if (!$source) {
fwrite(
STDERR,
"Usage : php scripts/asm3-import.php <dump.sql> [--apply]\nSans --apply, aucune donnée métier nest écrite.\n",
);
exit(2);
}
if ($apply) {
fwrite(STDERR, 'IMPORT RÉEL demandé. Tapez exactement IMPORTER ASM3 pour continuer : ');
if (trim((string) fgets(STDIN)) !== 'IMPORTER ASM3') {
fwrite(STDERR, "Import annulé.\n");
exit(3);
}
}
try {
Migrations::run($root . '/migrations');
$result = Asm3AnimalImporter::run($source, DB::pdo(), $apply);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
} catch (Throwable $e) {
fwrite(STDERR, "Échec : {$e->getMessage()}\n");
exit(1);
}

20
scripts/asm3-litters.php Normal file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
foreach (['DB', 'Migrations', 'BackupService', 'AppSettings', 'Asm3Analyzer', 'Asm3LitterImporter'] as $s) {
require $root . '/app/Services/' . $s . '.php';
}
$p = $argv[1] ?? null;
$apply = in_array('--apply', $argv, true);
if (!$p) {
exit(2);
}
if ($apply) {
fwrite(STDERR, 'Tapez IMPORTER PORTEES : ');
if (trim((string) fgets(STDIN)) !== 'IMPORTER PORTEES') {
exit(3);
}
}
Migrations::run($root . '/migrations');
echo json_encode(Asm3LitterImporter::run($p, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";

View file

@ -0,0 +1,38 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
foreach (['DB', 'Migrations', 'BackupService', 'AppSettings', 'Asm3Analyzer', 'Asm3MovementImporter'] as $service) {
require $root . '/app/Services/' . $service . '.php';
}
$source = null;
$apply = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--apply') {
$apply = true;
} elseif (str_starts_with($arg, '--')) {
fwrite(STDERR, "Option inconnue : {$arg}\n");
exit(2);
} else {
$source = $arg;
}
}
if (!$source) {
fwrite(STDERR, "Usage : php scripts/asm3-movements.php <dump.sql> [--apply]\n");
exit(2);
}
if ($apply) {
fwrite(STDERR, 'IMPORT MOUVEMENTS demandé. Tapez IMPORTER MOUVEMENTS : ');
if (trim((string) fgets(STDIN)) !== 'IMPORTER MOUVEMENTS') {
fwrite(STDERR, "Import annulé.\n");
exit(3);
}
}
try {
Migrations::run($root . '/migrations');
echo json_encode(Asm3MovementImporter::run($source, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
"\n";
} catch (Throwable $e) {
fwrite(STDERR, "Échec : {$e->getMessage()}\n");
exit(1);
}

28
scripts/asm3-plan.php Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/app/Services/DB.php';
require dirname(__DIR__) . '/app/Services/Asm3Analyzer.php';
require dirname(__DIR__) . '/app/Services/Asm3MigrationPlanner.php';
$source = $argv[1] ?? null;
$output = $argv[2] ?? null;
if (!$source) {
fwrite(STDERR, "Usage : php scripts/asm3-plan.php <dump.sql> [rapport.json]\n");
exit(2);
}
try {
$plan = Asm3MigrationPlanner::plan($source, DB::pdo());
$json = json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
} catch (Throwable $e) {
fwrite(STDERR, "Erreur : {$e->getMessage()}\n");
exit(1);
}
if ($output) {
if (file_put_contents($output, $json) === false) {
fwrite(STDERR, "Impossible décrire le rapport.\n");
exit(1);
}
echo "Plan écrit dans {$output}\n";
} else {
echo $json;
}

18
scripts/backup.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$root = dirname(__DIR__);
require_once $root . '/app/Services/DB.php';
require_once $root . '/app/Services/Migrations.php';
require_once $root . '/app/Services/I18n.php';
require_once $root . '/app/Services/AppSettings.php';
require_once $root . '/app/Services/BackupService.php';
Migrations::run($root . '/migrations');
I18n::setLocale(AppSettings::get('app_language'));
$backup = BackupService::create($argv[1] ?? 'cli');
fwrite(STDOUT, $backup['name'] . PHP_EOL);

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$fr = require $root . '/resources/lang/fr.php';
$en = require $root . '/resources/lang/en.php';
$used = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root . '/app', FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$source = file_get_contents($file->getPathname());
if ($source === false) {
continue;
}
preg_match_all("/\\bt(?:n)?\\(\\s*['\"]([^'\"]+)['\"]/", $source, $matches);
foreach ($matches[1] as $key) {
if (!str_ends_with($key, '.')) {
$used[$key] = true;
}
}
}
$missingFr = array_diff_key($used, $fr);
$missingEn = array_diff_key($used, $en);
$orphanFr = array_diff_key($fr, $en);
$orphanEn = array_diff_key($en, $fr);
foreach (
[
'fr manquantes' => $missingFr,
'en manquantes' => $missingEn,
'uniquement fr' => $orphanFr,
'uniquement en' => $orphanEn,
]
as $label => $keys
) {
if ($keys) {
fwrite(STDERR, $label . ' : ' . implode(', ', array_keys($keys)) . PHP_EOL);
}
}
$placeholderErrors = [];
foreach (array_intersect_key($fr, $en) as $key => $frText) {
preg_match_all('/:([a-zA-Z][a-zA-Z0-9_]*)/', (string) $frText, $frMatches);
preg_match_all('/:([a-zA-Z][a-zA-Z0-9_]*)/', (string) $en[$key], $enMatches);
$frVars = array_values(array_unique($frMatches[1]));
sort($frVars);
$enVars = array_values(array_unique($enMatches[1]));
sort($enVars);
if ($frVars !== $enVars) {
$placeholderErrors[] = $key . ' (FR: ' . implode(',', $frVars) . ' · EN: ' . implode(',', $enVars) . ')';
}
}
if ($placeholderErrors) {
fwrite(STDERR, 'variables incohérentes : ' . implode('; ', $placeholderErrors) . PHP_EOL);
}
$confirmationKeys = [
'data.confirm_reset_phrase',
'data.confirm_demo_phrase',
'asm3.confirm_import_phrase',
'asm3.confirm_replace_phrase',
];
$confirmationErrors = [];
foreach ($confirmationKeys as $key) {
if (trim((string) ($fr[$key] ?? '')) === '' || trim((string) ($en[$key] ?? '')) === '') {
$confirmationErrors[] = $key . ' vide';
} elseif ($fr[$key] === $en[$key]) {
$confirmationErrors[] = $key . ' identique en FR et EN';
}
}
if ($confirmationErrors) {
fwrite(STDERR, 'confirmations incohérentes : ' . implode('; ', $confirmationErrors) . PHP_EOL);
}
$rawOutput = [];
foreach (['/app/Controllers', '/app/Services'] as $relative) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root . $relative, FilesystemIterator::SKIP_DOTS),
);
foreach ($files as $file) {
if (
$file->getExtension() !== 'php' ||
in_array($file->getFilename(), ['DevController.php', 'Migrations.php'], true)
) {
continue;
}
$source = file_get_contents($file->getPathname()) ?: '';
if (
preg_match(
'/(?:\becho\s*|throw\s+new\s+(?:RuntimeException|InvalidArgumentException)\s*\()\s*[\'\"][A-Za-zÀ-ÿ]/u',
$source,
)
) {
$rawOutput[] = str_replace($root . '/', '', $file->getPathname());
}
}
}
if ($rawOutput) {
fwrite(STDERR, 'sorties utilisateur codées en dur : ' . implode(', ', array_unique($rawOutput)) . PHP_EOL);
}
if ($missingFr || $missingEn || $orphanFr || $orphanEn || $placeholderErrors || $confirmationErrors || $rawOutput) {
exit(1);
}
echo count($used) .
' clés utilisées · ' .
count($fr) .
' traductions FR/EN cohérentes · variables et confirmations vérifiées' .
PHP_EOL;

View file

@ -0,0 +1,165 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
require $root . '/app/Services/DB.php';
require $root . '/app/Services/BackupService.php';
require $root . '/app/Services/ImageService.php';
require $root . '/app/Services/Asm3Analyzer.php';
$apply = in_array('--apply', $argv, true);
$positional = array_values(
array_filter(array_slice($argv, 1), static fn($arg) => !str_starts_with((string) $arg, '--')),
);
$dump = $positional[0] ?? '';
$sourceDir = $positional[1] ?? '';
if ($dump === '' || $sourceDir === '') {
fwrite(STDERR, "Usage : php scripts/import-asm3-photos.php <dump.sql> <dossier-photos> [--apply]\n");
exit(2);
}
if (!is_file($dump) || !is_dir($sourceDir)) {
throw new RuntimeException('Dump ASM3 ou dossier des photos introuvable.');
}
$hash = hash_file('sha256', $dump);
$source = Asm3Analyzer::rows($dump, ['media', 'dbfs']);
$db = DB::pdo();
$links = [];
$q = $db->prepare("SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=? AND entity_type='animal'");
$q->execute([$hash]);
foreach ($q as $row) {
$links[(int) $row['source_id']] = (int) $row['target_id'];
}
$dbfs = [];
foreach ($source['dbfs'] ?? [] as $row) {
$dbfs[(int) $row['ID']] = $row;
}
$items = [];
$errors = [];
foreach ($source['media'] ?? [] as $media) {
if ((int) $media['LINKTYPEID'] !== 0 || !str_starts_with((string) $media['MEDIAMIMETYPE'], 'image/')) {
continue;
}
$sourceAnimal = (int) $media['LINKID'];
$targetAnimal = $links[$sourceAnimal] ?? 0;
$physical = $dbfs[(int) $media['DBFSID']] ?? null;
$filename = $physical ? basename(str_replace('file:', '', (string) $physical['URL'])) : '';
$path = $sourceDir . '/' . $filename;
if (!$targetAnimal) {
$errors[] = 'Animal ASM3 non relié : ' . $sourceAnimal;
} elseif ($filename === '' || !is_file($path)) {
$errors[] = 'Fichier absent pour le média ASM3 ' . $media['ID'];
} else {
$inspection = ImageService::inspect($path);
$items[] = [
'media' => $media,
'animal' => $targetAnimal,
'path' => $path,
'physical' => $filename,
'inspection' => $inspection,
];
}
}
if ($errors) {
throw new RuntimeException(implode("\n", $errors));
}
$targets = array_values(array_unique(array_column($items, 'animal')));
$primaryCount = count(array_filter($items, static fn(array $i): bool => (int) $i['media']['WEBSITEPHOTO'] === 1));
$summary = [
'mode' => $apply ? 'APPLIQUE' : 'SIMULATION',
'photos_found' => count($items),
'animals' => count($targets),
'primary_photos' => $primaryCount,
'public_photos' => count($items),
'already_imported' => 0,
'created' => 0,
'optimized' => 0,
'backup' => null,
];
foreach ($items as $item) {
$original = 'ASM3-' . (int) $item['media']['ID'] . '-' . (string) $item['media']['MEDIANAME'];
$s = $db->prepare('SELECT 1 FROM animal_photos WHERE animal_id=? AND original_name=?');
$s->execute([$item['animal'], $original]);
if ($s->fetchColumn()) {
$summary['already_imported']++;
}
}
if (!$apply) {
echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
exit();
}
$backup = BackupService::create('avant-import-photos-asm3');
$summary['backup'] = $backup['name'] ?? null;
$written = [];
$db->beginTransaction();
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(?,?,'completed','{}',?)",
)->execute([basename($dump) . ' · photos', $hash, $summary['backup']]);
$run = (int) $db->lastInsertId();
$reset = $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=?');
foreach ($targets as $animalId) {
$reset->execute([$animalId]);
}
$insert = $db->prepare(
'INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption,created_at) VALUES(?,?,?,?,?,?,1,?,COALESCE(?,datetime(\'now\')))',
);
$link = $db->prepare(
"INSERT OR IGNORE INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(?,'media',?,?,?)",
);
foreach ($items as $item) {
$media = $item['media'];
$original = 'ASM3-' . (int) $media['ID'] . '-' . (string) $media['MEDIANAME'];
$existing = $db->prepare('SELECT id FROM animal_photos WHERE animal_id=? AND original_name=?');
$existing->execute([$item['animal'], $original]);
$photoId = (int) $existing->fetchColumn();
if (!$photoId) {
$stored = ImageService::storeLocal(
$item['path'],
$root . '/public/media/animals/' . $item['animal'],
'asm3_' . (int) $media['ID'],
'photo',
);
$written[] = $stored['path'];
$caption = trim((string) ($media['MEDIANOTES'] ?? ''));
$caption = $caption !== '' ? 'Archive ASM3 · ' . $caption : 'Archive ASM3';
$insert->execute([
$item['animal'],
$stored['filename'],
$original,
$stored['mime'],
$stored['size'],
(int) $media['WEBSITEPHOTO'] === 1 ? 1 : 0,
$caption,
substr((string) $media['CREATEDDATE'], 0, 19) ?: null,
]);
$photoId = (int) $db->lastInsertId();
$summary['created']++;
if ($stored['optimized']) {
$summary['optimized']++;
}
} elseif ((int) $media['WEBSITEPHOTO'] === 1) {
$db->prepare('UPDATE animal_photos SET is_primary=1,is_public=1 WHERE id=?')->execute([$photoId]);
} else {
$db->prepare('UPDATE animal_photos SET is_public=1 WHERE id=?')->execute([$photoId]);
}
$link->execute([$hash, (int) $media['ID'], $photoId, $run]);
}
$db->prepare('UPDATE asm3_import_runs SET summary_json=? WHERE id=?')->execute([
json_encode($summary, JSON_UNESCAPED_UNICODE),
$run,
]);
if ($db->query('PRAGMA foreign_key_check')->fetchAll()) {
throw new RuntimeException('Violation de clé étrangère après import.');
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
foreach ($written as $path) {
@unlink($path);
}
throw $e;
}
echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";

47
scripts/maintenance.php Normal file
View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit();
}
foreach (
[
'DB',
'Migrations',
'I18n',
'AppSettings',
'Auth',
'AuditService',
'BackupService',
'NotificationService',
'PrivacyService',
]
as $class
) {
require_once $root . '/app/Services/' . $class . '.php';
}
Migrations::run($root . '/migrations');
I18n::setLocale(AppSettings::get('app_language'));
$command = $argv[1] ?? 'run';
try {
$results = [];
if (in_array($command, ['run', 'backup'], true)) {
$results['backup'] = BackupService::runScheduled($command === 'backup' && in_array('--force', $argv, true));
}
if (in_array($command, ['run', 'privacy'], true)) {
$results['privacy'] = PrivacyService::maintenance();
}
if (in_array($command, ['run', 'notifications'], true)) {
$results['notifications'] = NotificationService::send(false);
}
echo json_encode(
['ok' => true, 'at' => date(DATE_ATOM), 'results' => $results],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE,
),
PHP_EOL;
} catch (Throwable $e) {
fwrite(STDERR, json_encode(['ok' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE) . PHP_EOL);
exit(1);
}

View file

@ -0,0 +1,173 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
require $root . '/app/Services/DB.php';
require $root . '/app/Services/BackupService.php';
$apply = in_array('--apply', $argv, true);
$db = DB::pdo();
function contactKey(string $value): string
{
$value = mb_strtolower(trim($value));
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if ($ascii !== false) {
$value = $ascii;
}
$parts = preg_split('/[^a-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY);
sort($parts);
return implode(' ', $parts);
}
function filledScore(array $contact): int
{
$score = 0;
foreach (['phone', 'email', 'address', 'postal_code', 'city', 'notes'] as $field) {
if (trim((string) ($contact[$field] ?? '')) !== '') {
$score++;
}
}
return $score;
}
$allContacts = $db
->query("SELECT * FROM directory_contacts WHERE deleted_at IS NULL AND kind='person'")
->fetchAll(PDO::FETCH_ASSOC);
$byKey = [];
foreach ($allContacts as $contact) {
$byKey[contactKey($contact['name'])][] = $contact;
}
$groups = array_filter($byKey, static fn(array $rows): bool => count($rows) > 1);
$singletons = array_values(array_filter($byKey, static fn(array $rows): bool => count($rows) === 1));
$used = [];
for ($i = 0; $i < count($singletons); $i++) {
for ($j = $i + 1; $j < count($singletons); $j++) {
$a = $singletons[$i][0];
$b = $singletons[$j][0];
if (isset($used[$a['id']]) || isset($used[$b['id']])) {
continue;
}
$ka = contactKey($a['name']);
$kb = contactKey($b['name']);
if (min(strlen($ka), strlen($kb)) >= 8 && levenshtein($ka, $kb) <= 1) {
$groups['fuzzy:' . $a['id'] . ':' . $b['id']] = [$a, $b];
$used[$a['id']] = $used[$b['id']] = true;
}
}
}
$foreignKeys = [];
foreach (
$db
->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
->fetchAll(PDO::FETCH_COLUMN)
as $table
) {
foreach (
$db->query('PRAGMA foreign_key_list("' . str_replace('"', '""', $table) . '")')->fetchAll(PDO::FETCH_ASSOC)
as $fk
) {
if ($fk['table'] === 'directory_contacts' && $table !== 'directory_contact_roles') {
$foreignKeys[] = [$table, $fk['from']];
}
}
}
$summary = ['groups' => count($groups), 'contacts_merged' => 0, 'relations_moved' => 0];
$details = [];
$backup = null;
if ($apply) {
$backup = BackupService::create('avant-fusion-annuaire');
}
$db->beginTransaction();
try {
foreach ($groups as $key => $contacts) {
usort($contacts, static function (array $a, array $b): int {
$byDetails = filledScore($b) <=> filledScore($a);
return $byDetails !== 0 ? $byDetails : (int) $a['id'] <=> (int) $b['id'];
});
$canonical = array_shift($contacts);
$canonicalId = (int) $canonical['id'];
$mergedNames = [$canonical['name']];
foreach ($contacts as $duplicate) {
$duplicateId = (int) $duplicate['id'];
$mergedNames[] = $duplicate['name'];
foreach ($foreignKeys as [$table, $column]) {
$countStmt = $db->prepare("SELECT COUNT(*) FROM \"$table\" WHERE \"$column\"=?");
$countStmt->execute([$duplicateId]);
$count = (int) $countStmt->fetchColumn();
if ($count) {
$db->prepare("UPDATE \"$table\" SET \"$column\"=? WHERE \"$column\"=?")->execute([
$canonicalId,
$duplicateId,
]);
$summary['relations_moved'] += $count;
}
}
$roles = $db->prepare('SELECT role FROM directory_contact_roles WHERE contact_id=?');
$roles->execute([$duplicateId]);
foreach ($roles->fetchAll(PDO::FETCH_COLUMN) as $role) {
$db->prepare('INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(?,?)')->execute([
$canonicalId,
$role,
]);
}
$db->prepare('DELETE FROM directory_contact_roles WHERE contact_id=?')->execute([$duplicateId]);
foreach (
['phone', 'email', 'address', 'postal_code', 'city', 'country', 'organization_id', 'notes']
as $field
) {
if (empty($canonical[$field]) && !empty($duplicate[$field])) {
$canonical[$field] = $duplicate[$field];
}
}
$db->prepare('DELETE FROM directory_contacts WHERE id=?')->execute([$duplicateId]);
$summary['contacts_merged']++;
}
$db->prepare(
'UPDATE directory_contacts SET phone=?,email=?,address=?,postal_code=?,city=?,country=?,organization_id=?,notes=?,updated_at=datetime(\'now\') WHERE id=?',
)->execute([
$canonical['phone'] ?: null,
$canonical['email'] ?: null,
$canonical['address'] ?: null,
$canonical['postal_code'] ?: null,
$canonical['city'] ?: null,
$canonical['country'] ?: 'France',
$canonical['organization_id'] ?: null,
$canonical['notes'] ?: null,
$canonicalId,
]);
foreach (array_unique($mergedNames) as $oldName) {
$db->prepare(
'UPDATE adoptions SET adopter_name=? WHERE adopter_contact_id=? AND lower(trim(adopter_name))=lower(trim(?))',
)->execute([$canonical['name'], $canonicalId, $oldName]);
$db->prepare(
'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))',
)->execute([$canonical['name'], $oldName]);
}
$details[] = ['kept_id' => $canonicalId, 'kept_name' => $canonical['name'], 'merged_names' => $mergedNames];
}
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll();
if ($fk) {
throw new RuntimeException('Violation de clé étrangère après fusion.');
}
if ($apply) {
$db->commit();
} else {
$db->rollBack();
}
echo json_encode(
[
'mode' => $apply ? 'APPLIQUE' : 'SIMULATION',
'backup' => $backup['name'] ?? null,
'summary' => $summary,
'groups' => $details,
],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
),
"\n";
} catch (Throwable $error) {
if ($db->inTransaction()) {
$db->rollBack();
}
fwrite(STDERR, 'Fusion interrompue : ' . $error->getMessage() . "\n");
exit(1);
}

18
scripts/migrate.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$root = dirname(__DIR__);
require_once $root . '/app/Services/DB.php';
require_once $root . '/app/Services/Migrations.php';
Migrations::run($root . '/migrations');
$integrity = DB::pdo()->query('PRAGMA integrity_check')->fetchColumn();
if ($integrity !== 'ok') {
fwrite(STDERR, "SQLite integrity check failed: $integrity\n");
exit(2);
}
fwrite(STDOUT, "Migrations applied; SQLite integrity: ok\n");

View file

@ -0,0 +1,106 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
require $root . '/app/Services/DB.php';
require $root . '/app/Services/BackupService.php';
$apply = in_array('--apply', $argv, true);
$db = DB::pdo();
$backup = null;
$user =
(int) ($db->query("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id LIMIT 1")->fetchColumn() ?:
0) ?:
null;
$latest = $db
->query(
"WITH ranked AS (SELECT ap.*,ROW_NUMBER() OVER(PARTITION BY animal_id ORDER BY date(event_date) DESC,id DESC) rn FROM animal_placements ap) SELECT r.*,a.status current_status,a.archived_at current_archived,dc.name contact_name,dc.address contact_address,dc.postal_code contact_postal,dc.city contact_city,ad.adopter_address,ad.adopter_postal_code,ad.adopter_city FROM ranked r JOIN animals a ON a.id=r.animal_id LEFT JOIN animal_deaths d ON d.animal_id=a.id LEFT JOIN directory_contacts dc ON dc.id=r.contact_id LEFT JOIN adoptions ad ON ad.id=r.adoption_id WHERE r.rn=1 AND r.event_type='adoption' AND d.animal_id IS NULL AND a.deleted_at IS NULL ORDER BY r.event_date,r.animal_id",
)
->fetchAll(PDO::FETCH_ASSOC);
$summary = [
'adopted_states_checked' => count($latest),
'statuses_fixed' => 0,
'locations_fixed' => 0,
'movements_created' => 0,
'movements_updated' => 0,
'history_created' => 0,
];
if ($apply) {
$backup = BackupService::create('avant-reconciliation-etat-animaux');
}
$db->beginTransaction();
try {
foreach ($latest as $row) {
$animal = (int) $row['animal_id'];
$date = (string) $row['event_date'];
$contact = trim((string) ($row['contact_name'] ?? '')) ?: 'Adoptant non renseigné';
$address = implode(
' ',
array_filter([
trim((string) ($row['contact_address'] ?? '' ?: $row['adopter_address'] ?? '')),
trim((string) ($row['contact_postal'] ?? '' ?: $row['adopter_postal_code'] ?? '')),
trim((string) ($row['contact_city'] ?? '' ?: $row['adopter_city'] ?? '')),
]),
);
if ($address === '') {
$address = 'Adopté par ' . $contact;
}
if ($row['current_status'] !== 'adopte' || empty($row['current_archived'])) {
$summary['statuses_fixed']++;
}
$current = $db->prepare('SELECT current_address FROM animals WHERE id=?');
$current->execute([$animal]);
if (trim((string) $current->fetchColumn()) !== $address) {
$summary['locations_fixed']++;
}
$db->prepare(
"UPDATE animals SET status='adopte',is_archived=1,archived_at=?,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,current_address=?,current_lat=NULL,current_lng=NULL,updated_at=datetime('now') WHERE id=?",
)->execute([$date . ' 12:00:00', $address, $animal]);
$movement = $db->prepare(
"SELECT id FROM animal_movements WHERE animal_id=? AND kind='exit' AND lower(COALESCE(lieu,'')) LIKE 'adopt%' ORDER BY created_at DESC,id DESC LIMIT 1",
);
$movement->execute([$animal]);
$movementId = (int) $movement->fetchColumn();
if ($movementId) {
$db->prepare(
"UPDATE animal_movements SET place=?,lieu='Adoption',contact_name=?,note=CASE WHEN note IS NULL OR note='' THEN 'Sortie synchronisée avec le parcours dadoption.' ELSE note END,created_at=? WHERE id=?",
)->execute([$address, $contact, $date . ' 12:00:00', $movementId]);
$summary['movements_updated']++;
} else {
$db->prepare(
"INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,note,created_at) VALUES(?,'exit',?,'Adoption',?,'Sortie synchronisée avec le parcours dadoption.',?)",
)->execute([$animal, $address, $contact, $date . ' 12:00:00']);
$summary['movements_created']++;
}
$history = $db->prepare(
"SELECT 1 FROM animal_history WHERE animal_id=? AND date(created_at)=date(?) AND (lower(label) LIKE '%adopt%' OR lower(type)='adoption') LIMIT 1",
);
$history->execute([$animal, $date]);
if (!$history->fetchColumn()) {
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id,created_at) VALUES(?,'status','Adoption de lanimal',?,?,?)",
)->execute([$animal, 'Adopté par ' . $contact . '.', $user, $date . ' 12:00:00']);
$summary['history_created']++;
}
}
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll();
if ($fk) {
throw new RuntimeException('Violation de clé étrangère après synchronisation.');
}
if ($apply) {
$db->commit();
} else {
$db->rollBack();
}
echo json_encode(
['mode' => $apply ? 'APPLIQUE' : 'SIMULATION', 'backup' => $backup['name'] ?? null, 'summary' => $summary],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
),
"\n";
} catch (Throwable $error) {
if ($db->inTransaction()) {
$db->rollBack();
}
fwrite(STDERR, 'Synchronisation interrompue : ' . $error->getMessage() . "\n");
exit(1);
}

16
scripts/rollback.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
root_dir="$(cd "$(dirname "$0")/.." && pwd)"
snapshot="${1:-}"
if [[ -z "$snapshot" || ! -f "$snapshot" || "$(basename "$snapshot")" != code-*.tar.gz ]]; then
echo "Usage: $0 storage/releases/code-VERSION-DATE.tar.gz" >&2
exit 2
fi
case "$(realpath "$snapshot")" in "$root_dir"/storage/releases/*) ;; *) echo "Snapshot hors de storage/releases refusé." >&2; exit 2;; esac
touch "$root_dir/storage/maintenance.flag"
trap 'rm -f "$root_dir/storage/maintenance.flag"' EXIT
tar -xzf "$snapshot" -C "$root_dir"
php "$root_dir/scripts/migrate.php"
rm -f "$root_dir/storage/maintenance.flag"
echo "Code restauré. La base na pas été rétrogradée automatiquement."

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit();
}
$root = dirname(__DIR__);
require $root . '/app/Services/DB.php';
require $root . '/app/Services/Migrations.php';
require $root . '/app/Services/I18n.php';
require $root . '/app/Services/AppSettings.php';
require $root . '/app/Services/Auth.php';
require $root . '/app/Services/BackupService.php';
require $root . '/app/Services/NotificationService.php';
Migrations::run($root . '/migrations');
I18n::setLocale(AppSettings::get('app_language'));
$result = NotificationService::send(false);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(($result['sent'] ?? false) || in_array($result['reason'] ?? '', ['disabled', 'not_due'], true) ? 0 : 1);

43
scripts/update.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
set -euo pipefail
root_dir="$(cd "$(dirname "$0")/.." && pwd)"
release_archive="${1:-}"
if [[ -z "$release_archive" || ! -f "$release_archive" ]]; then
echo "Usage: $0 /chemin/vers/globinours.tar.gz" >&2
exit 2
fi
if [[ ! -f "$root_dir/public/index.php" || ! -f "$root_dir/VERSION" ]]; then
echo "Installation Globinours invalide: $root_dir" >&2
exit 2
fi
stage_dir="$(mktemp -d /tmp/globinours-update-XXXXXX)"
trap 'rm -rf "$stage_dir"; rm -f "$root_dir/storage/maintenance.flag"' EXIT
if tar -tzf "$release_archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
echo "Archive refusée: chemin dangereux." >&2
exit 3
fi
tar -xzf "$release_archive" -C "$stage_dir"
source_dir="$stage_dir"
if [[ ! -f "$source_dir/public/index.php" ]]; then
first_dir="$(find "$stage_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
source_dir="$first_dir"
fi
if [[ ! -f "$source_dir/public/index.php" || ! -f "$source_dir/VERSION" ]]; then
echo "Archive Globinours invalide." >&2
exit 3
fi
mkdir -p "$root_dir/storage/releases"
current_version="$(tr -d '[:space:]' < "$root_dir/VERSION")"
new_version="$(tr -d '[:space:]' < "$source_dir/VERSION")"
rollback_archive="$root_dir/storage/releases/code-${current_version}-$(date +%Y%m%d-%H%M%S).tar.gz"
php "$root_dir/scripts/backup.php" pre-upgrade
tar --exclude='./data' --exclude='./storage' --exclude='./public/media' --exclude='./.env' -czf "$rollback_archive" -C "$root_dir" .
touch "$root_dir/storage/maintenance.flag"
rsync -a --delete --exclude=data --exclude=storage --exclude=public/media --exclude=.env "$source_dir/" "$root_dir/"
php "$root_dir/scripts/migrate.php"
rm -f "$root_dir/storage/maintenance.flag"
echo "Globinours $current_version -> $new_version"
echo "Retour arrière du code: $rollback_archive"