Préparer la version 1.0.0-rc.4

This commit is contained in:
Alexandre NOEL 2026-09-07 14:15:45 +02:00
commit 6e3beea211
38 changed files with 388 additions and 56 deletions

View file

@ -22,6 +22,8 @@ final class AppSettings
'app_language' => 'fr',
'public_site_enabled' => '0',
'public_site_theme' => 'warm',
'public_site_name' => '',
'public_site_custom_css' => '',
'public_site_routing' => 'integrated',
'public_site_home_title' => 'Ils attendent une famille',
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.',
@ -35,6 +37,10 @@ final class AppSettings
'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.',
'public_site_facebook_url' => '',
'public_site_instagram_url' => '',
'public_site_mastodon_url' => '',
'public_site_youtube_url' => '',
'public_site_tiktok_url' => '',
'public_site_bluesky_url' => '',
'notification_email_enabled' => '0',
'notification_email_frequency' => 'weekly',
'notification_email_recipient' => '',

View file

@ -87,11 +87,12 @@ final class IcadService
throw new RuntimeException(t('service.icad.credentials_missing'));
}
$cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie';
$cookie = sys_get_temp_dir() . '/globinours_icad_' . bin2hex(random_bytes(6)) . '.cookie';
try {
// 1) GET HOME => token _token du formulaire login ACTEUR
$r1 = $this->req('https://www.i-cad.fr/', $cookie);
$this->assertSuccess($r1, t('service.icad.home_unavailable'));
$token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé
@ -109,16 +110,19 @@ final class IcadService
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/',
]);
$this->assertSuccess($r2, t('service.icad.login_failed'));
// 3) Vérif session
$r2b = $this->req('https://www.i-cad.fr/pro', $cookie);
if (stripos($r2b['body'], 'Se connecter') !== false) {
$this->assertSuccess($r2b, t('service.icad.login_failed'));
if (stripos($r2b['body'], 'Se connecter') !== false || str_contains((string)$r2b['effective_url'], '/login')) {
throw new RuntimeException(t('service.icad.login_failed'));
}
// 4) GET recherche. I-CAD a renommé le formulaire
// search_animal en animal_search en 2026 : on accepte les deux.
$r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie);
$this->assertSuccess($r3, t('service.icad.search_unavailable'));
[$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']);
// 5) POST recherche
@ -136,6 +140,7 @@ final class IcadService
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/identification/recherche',
]);
$this->assertSuccess($r4, t('service.icad.search_unavailable'));
$animalPath = $this->extractAnimalPath($r4);
if (!$animalPath) {
@ -144,6 +149,7 @@ final class IcadService
// 6) GET fiche animal
$r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie);
$this->assertSuccess($r5, t('service.icad.record_unavailable'));
$data = $this->parseAnimalHtml($r5['body'], $animalPath);
$this->saveCache($animalId, $chipId, 'ok', null, $data);
@ -158,6 +164,14 @@ final class IcadService
// ========= HTTP =========
private function assertSuccess(array $response, string $message): void
{
$status = (int) ($response['status'] ?? 0);
if ($status < 200 || $status >= 400) {
throw new RuntimeException($message . ' (HTTP ' . $status . ')');
}
}
private function req(
string $url,
string $cookieFile,

View file

@ -33,4 +33,23 @@ final class LabReferenceService
}
return $out;
}
public static function copySpecies(string $source, string $target, ?int $userId): int
{
$source = SpeciesService::normalize($source);
$target = SpeciesService::normalize($target);
if ($source === $target || !SpeciesService::exists($source, false) || !SpeciesService::exists($target, false)) {
throw new RuntimeException(t('lab_reference.invalid_species'));
}
$stmt = DB::pdo()->prepare(
"INSERT OR IGNORE INTO lab_reference_ranges(analyzer_code,analyzer_name,species_code,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,updated_by,updated_at)
SELECT analyzer_code,analyzer_name,:target,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,:user,datetime('now')
FROM lab_reference_ranges WHERE species_code=:source",
);
$stmt->execute([':source' => $source, ':target' => $target, ':user' => $userId]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException(t('lab_reference.nothing_copied'));
}
return $stmt->rowCount();
}
}

View file

@ -21,12 +21,13 @@ final class PrivateMediaService
return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
}
public static function ensure(string $dir): void
public static function ensure(string $dir, bool $public = false): void
{
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
$mode = $public ? 0755 : 0700;
if (!is_dir($dir) && !mkdir($dir, $mode, true) && !is_dir($dir)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
@chmod($dir, 0700);
@chmod($dir, $mode);
}
public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void
@ -45,7 +46,7 @@ final class PrivateMediaService
}
throw new RuntimeException(t('service.private_media.not_found'));
}
self::ensure($to);
self::ensure($to, $toPublic);
if (!rename($source, $target)) {
throw new RuntimeException(t('service.private_media.move_failed'));
}
@ -55,6 +56,19 @@ final class PrivateMediaService
public static function migrateLegacy(): void
{
$db = DB::pdo();
$public = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=1')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($public as $row) {
$dir = self::publicDir((int) $row['animal_id']);
if (is_dir($dir)) {
@chmod($dir, 0755);
}
$file = $dir . '/' . basename((string) $row['filename']);
if (is_file($file)) {
@chmod($file, 0644);
}
}
$private = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
->fetchAll(PDO::FETCH_ASSOC);

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
final class PublicContentFormatter
{
public static function render(string $text): string
{
$lines = preg_split('/\R/u', trim($text)) ?: [];
$html = [];
$listOpen = false;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
}
continue;
}
if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) {
if (!$listOpen) {
$html[] = '<ul>';
$listOpen = true;
}
$html[] = '<li>' . self::inline($match[1]) . '</li>';
continue;
}
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
}
if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) {
$level = strlen($match[1]) + 1;
$html[] = '<h' . $level . '>' . self::inline($match[2]) . '</h' . $level . '>';
continue;
}
$html[] = '<p>' . self::inline($line) . '</p>';
}
if ($listOpen) {
$html[] = '</ul>';
}
return implode("\n", $html);
}
private static function inline(string $text): string
{
$links = [];
$text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string {
if (!filter_var($match[2], FILTER_VALIDATE_URL)) {
return $match[0];
}
$token = "\x1A" . count($links) . "\x1A";
$links[] = '<a href="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') .
'" rel="noopener noreferrer">' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>';
return $token;
}, $text) ?? $text;
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$text = preg_replace('/\*\*(.+?)\*\*/u', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $text) ?? $text;
foreach ($links as $index => $link) {
$text = str_replace("\x1A" . $index . "\x1A", $link, $text);
}
return $text;
}
}

View file

@ -607,9 +607,14 @@ final class StatisticsService
"SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$hairTypes = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.hair_type),''),'Non renseigné') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$species = $rows(
$entered .
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'chat' WHEN 'dog' THEN 'chien' ELSE COALESCE(NULLIF(TRIM(lower(a.species)),''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeTypes = $rows(
@ -680,6 +685,7 @@ final class StatisticsService
return [
'colors' => $colors,
'breeds' => $breeds,
'hairTypes' => $hairTypes,
'species' => $species,
'intakeTypes' => $intakeTypes,
'intakeReasons' => $intakeReasons,