651 lines
26 KiB
PHP
651 lines
26 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use DateTimeImmutable;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Import des vacances scolaires depuis l'API publique data.education.gouv.fr.
|
||
*
|
||
* Ce service ne fait que PROPOSER : il ne touche jamais la base. L'écriture
|
||
* relève de VacationModel, après validation manuelle par un utilisateur. Ce
|
||
* découpage est délibéré — les libellés et découpages publiés varient d'une
|
||
* académie et d'une année à l'autre, et rien de tout cela n'entre en base sans
|
||
* relecture humaine.
|
||
*
|
||
* TOLÉRANCE AUX PANNES, principe directeur de tout le fichier. Le portail public
|
||
* change de version d'API, de graphie d'académie et de format de réponse sans
|
||
* préavis. Chaque étape est donc doublée d'un repli, et un échec partiel produit
|
||
* un AVERTISSEMENT plutôt qu'une erreur : mieux vaut proposer les dates
|
||
* disponibles que rien du tout.
|
||
*
|
||
* Nécessite un accès réseau SORTANT depuis le conteneur php.
|
||
*/
|
||
final class SchoolHolidayApiService
|
||
{
|
||
/** Jeu de données public du calendrier scolaire. */
|
||
private const DATASET = 'fr-en-calendrier-scolaire';
|
||
|
||
/**
|
||
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
|
||
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
|
||
* la page continue à fonctionner lors d'une évolution du portail.
|
||
*/
|
||
private const EXPLORE_ENDPOINTS = [
|
||
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
|
||
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
|
||
];
|
||
|
||
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
|
||
|
||
/**
|
||
* Zone de vacances (A, B ou C) de chaque académie métropolitaine.
|
||
*
|
||
* Table locale servant de REPLI quand l'API ne publie pas de ligne propre à
|
||
* l'académie mais seulement une ligne commune à sa zone. À mettre à jour si
|
||
* le découpage officiel évolue — il a déjà changé par le passé, et les
|
||
* académies d'outre-mer n'y figurent pas.
|
||
*/
|
||
private const ACADEMY_ZONES = [
|
||
'dijon' => 'Zone A'
|
||
];
|
||
|
||
/**
|
||
* Compatibilité avec le code existant : retourne uniquement les périodes,
|
||
* sans les avertissements. Le code actuel appelle plutôt la variante
|
||
* détaillée, qui permet d'afficher ce qui n'a pas pu être chargé.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public function previewCalendarYear(string $academy, int $calendarYear): array
|
||
{
|
||
return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods'];
|
||
}
|
||
|
||
/**
|
||
* Retourne toutes les périodes recouvrant une année civile, accompagnées
|
||
* d'avertissements non bloquants. Une année civile chevauche deux années
|
||
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
|
||
* empêcher l'affichage des dates déjà disponibles.
|
||
*
|
||
* Une année civile couvre la FIN d'une année scolaire et le DÉBUT de la
|
||
* suivante : les deux sont donc interrogées, puis les périodes fusionnées et
|
||
* dédoublonnées sur (libellé, début, fin). Ne sont conservées que celles qui
|
||
* INTERSECTENT l'année civile demandée.
|
||
*
|
||
* L'exception n'est levée que si AUCUNE des deux requêtes n'a abouti : un
|
||
* seul succès suffit à rendre un résultat exploitable, l'échec de l'autre
|
||
* partant en avertissement.
|
||
*
|
||
* @return array{periods: array, warnings: array, school_years: array}
|
||
*
|
||
* @throws RuntimeException Année ou académie invalide, ou API totalement
|
||
* injoignable.
|
||
*/
|
||
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
|
||
{
|
||
if ($calendarYear < 2000 || $calendarYear > 2100) {
|
||
throw new RuntimeException('Année civile invalide.');
|
||
}
|
||
|
||
$academy = trim($academy);
|
||
if ($academy === '') {
|
||
throw new RuntimeException('Académie manquante.');
|
||
}
|
||
|
||
$schoolYears = [
|
||
($calendarYear - 1) . '-' . $calendarYear,
|
||
$calendarYear . '-' . ($calendarYear + 1),
|
||
];
|
||
|
||
$all = [];
|
||
$warnings = [];
|
||
$successfulQueries = 0;
|
||
|
||
foreach ($schoolYears as $schoolYear) {
|
||
try {
|
||
$periods = $this->previewSchoolYear($academy, $schoolYear);
|
||
$successfulQueries++;
|
||
|
||
if ($periods === []) {
|
||
$warnings[] = sprintf(
|
||
'Aucune date publiée pour l’académie %s et l’année scolaire %s.',
|
||
$academy,
|
||
$schoolYear
|
||
);
|
||
}
|
||
|
||
foreach ($periods as $period) {
|
||
$key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin'];
|
||
$all[$key] = $period;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$warnings[] = sprintf(
|
||
'Les dates de l’année scolaire %s n’ont pas pu être chargées : %s',
|
||
$schoolYear,
|
||
$e->getMessage()
|
||
);
|
||
}
|
||
}
|
||
|
||
if ($successfulQueries === 0) {
|
||
throw new RuntimeException(implode(' ', $warnings));
|
||
}
|
||
|
||
$yearStart = sprintf('%04d-01-01', $calendarYear);
|
||
$yearEnd = sprintf('%04d-12-31', $calendarYear);
|
||
|
||
$periods = array_values(array_filter(
|
||
$all,
|
||
static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart
|
||
));
|
||
|
||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||
|
||
return [
|
||
'periods' => $periods,
|
||
'warnings' => array_values(array_unique($warnings)),
|
||
'school_years' => $schoolYears,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Compatibilité avec l'ancienne API interne V5.17, qui raisonnait en année
|
||
* scolaire et non en année civile.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public function preview(string $academy, string $schoolYear): array
|
||
{
|
||
return $this->previewSchoolYear($academy, $schoolYear);
|
||
}
|
||
|
||
/**
|
||
* Interroge l'API pour UNE année scolaire, avec trois stratégies en cascade.
|
||
*
|
||
* On s'arrête au PREMIER résultat non vide ; les erreurs intermédiaires sont
|
||
* accumulées et seule la dernière est remontée si tout échoue. Un résultat
|
||
* vide sans erreur n'est pas une anomalie : l'académie peut simplement ne
|
||
* rien avoir publié.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*
|
||
* @throws RuntimeException Si toutes les stratégies ont échoué en erreur.
|
||
*/
|
||
private function previewSchoolYear(string $academy, string $schoolYear): array
|
||
{
|
||
$errors = [];
|
||
|
||
// 1. Recherche par académie. On essaie plusieurs graphies afin de ne
|
||
// pas rendre la recherche dépendante des accents ou du type de tiret.
|
||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||
try {
|
||
$records = $this->fetchExploreRecords([
|
||
'limit' => 100,
|
||
'lang' => 'fr',
|
||
'timezone' => 'Europe/Paris',
|
||
'order_by' => 'start_date',
|
||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||
'refine' => 'location:' . $this->quoteWhereValue($academyVariant),
|
||
]);
|
||
|
||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||
if ($periods !== []) {
|
||
return $periods;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$errors[] = $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// 2. Repli par zone. Il est utile lorsque le portail fournit une
|
||
// période commune à toute la zone, sans ligne spécifique à l'académie.
|
||
$zone = $this->zoneForAcademy($academy);
|
||
if ($zone !== null) {
|
||
try {
|
||
$records = $this->fetchExploreRecords([
|
||
'limit' => 100,
|
||
'lang' => 'fr',
|
||
'timezone' => 'Europe/Paris',
|
||
'order_by' => 'start_date',
|
||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||
'refine' => 'zones:' . $this->quoteWhereValue($zone),
|
||
]);
|
||
|
||
$periods = $this->normalize($records, $academy, $schoolYear, true);
|
||
if ($periods !== []) {
|
||
return $periods;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$errors[] = $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un
|
||
// format JSON différent, normalisé dans fetchLegacyRecords().
|
||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||
try {
|
||
$records = $this->fetchLegacyRecords($academyVariant, $schoolYear);
|
||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||
if ($periods !== []) {
|
||
return $periods;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$errors[] = $e->getMessage();
|
||
}
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
throw new RuntimeException(end($errors) ?: 'Erreur inconnue de l’API du calendrier scolaire.');
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Interroge l'API Explore, en essayant successivement chaque version
|
||
* d'endpoint jusqu'à obtenir une réponse exploitable.
|
||
*
|
||
* @param array<string, mixed> $parameters Paramètres de requête Explore.
|
||
*
|
||
* @return array<int, mixed> Contenu brut de la clé `results`.
|
||
*
|
||
* @throws RuntimeException Si aucun endpoint n'a répondu correctement.
|
||
*/
|
||
private function fetchExploreRecords(array $parameters): array
|
||
{
|
||
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||
$lastError = null;
|
||
|
||
foreach (self::EXPLORE_ENDPOINTS as $endpoint) {
|
||
try {
|
||
$payload = $this->fetchJson($endpoint . '?' . $query);
|
||
$records = $payload['results'] ?? null;
|
||
if (!is_array($records)) {
|
||
throw new RuntimeException('Réponse inattendue de l’API Explore.');
|
||
}
|
||
return $records;
|
||
} catch (RuntimeException $e) {
|
||
$lastError = $e;
|
||
}
|
||
}
|
||
|
||
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
|
||
}
|
||
|
||
/**
|
||
* Dernier repli : l'ancienne API Opendatasoft 1.0.
|
||
*
|
||
* Son format diffère — les champs utiles sont imbriqués sous `fields` — et
|
||
* sont remis à plat ici pour que normalize() traite les deux sources de la
|
||
* même façon.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*
|
||
* @throws RuntimeException Si la réponse n'a pas la forme attendue.
|
||
*/
|
||
private function fetchLegacyRecords(string $academy, string $schoolYear): array
|
||
{
|
||
$query = http_build_query([
|
||
'dataset' => self::DATASET,
|
||
'rows' => 100,
|
||
'sort' => 'start_date',
|
||
'refine.location' => $academy,
|
||
'refine.annee_scolaire' => $schoolYear,
|
||
], '', '&', PHP_QUERY_RFC3986);
|
||
|
||
$payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query);
|
||
$records = $payload['records'] ?? null;
|
||
if (!is_array($records)) {
|
||
throw new RuntimeException('Réponse inattendue de l’ancienne API du calendrier scolaire.');
|
||
}
|
||
|
||
return array_values(array_filter(array_map(
|
||
static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null)
|
||
? $record['fields']
|
||
: null,
|
||
$records
|
||
)));
|
||
}
|
||
|
||
/**
|
||
* Convertit les enregistrements bruts de l'API en périodes exploitables.
|
||
*
|
||
* Trois filtres successifs :
|
||
* 1. la POPULATION doit concerner les élèves — un enregistrement réservé
|
||
* aux enseignants ne fait pas de vacances pour l'accueil de loisirs ;
|
||
* 2. hors repli par zone, l'ACADÉMIE doit correspondre à celle demandée,
|
||
* comparée après normalisation (accents et tirets neutralisés) ;
|
||
* 3. la DESCRIPTION doit ressembler à des vacances (voir
|
||
* isVacationDescription()), afin d'écarter rentrées et pré-rentrées.
|
||
*
|
||
* CONVERSION DE DATE ESSENTIELLE : l'API publie la DATE DE REPRISE des
|
||
* cours, alors que PTA raisonne en bornes INCLUSIVES. Le dernier jour de
|
||
* vacances est donc la veille de la reprise. Sans cette correction, chaque
|
||
* période gagnerait un jour et le premier jour de classe serait qualifié
|
||
* d'extrascolaire.
|
||
*
|
||
* @param array<int, mixed> $records
|
||
* @param bool $zoneFallback true quand les données viennent du repli par
|
||
* zone : le filtre sur l'académie est alors levé, et l'académie
|
||
* demandée est conservée dans le résultat.
|
||
*
|
||
* @return array<int, array<string, mixed>> Triées par date de début.
|
||
*/
|
||
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
|
||
{
|
||
$periods = [];
|
||
$requestedAcademy = $this->normalizeAcademy($academy);
|
||
|
||
foreach ($records as $record) {
|
||
if (!is_array($record)) {
|
||
continue;
|
||
}
|
||
|
||
// Certaines vacances communes sont publiées avec population "-".
|
||
// En filtrant uniquement "Élèves", elles disparaissaient du résultat.
|
||
if (!$this->isStudentPopulation($record['population'] ?? '')) {
|
||
continue;
|
||
}
|
||
|
||
$recordAcademy = trim($this->stringValue($record['location'] ?? ''));
|
||
if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) {
|
||
continue;
|
||
}
|
||
|
||
$description = trim($this->stringValue($record['description'] ?? ''));
|
||
if (!$this->isVacationDescription($description)) {
|
||
continue;
|
||
}
|
||
|
||
$startRaw = $this->stringValue($record['start_date'] ?? '');
|
||
$endRaw = $this->stringValue($record['end_date'] ?? '');
|
||
$start = $this->dateOnly($startRaw);
|
||
$returnDate = $this->dateOnly($endRaw);
|
||
if ($start === null || $returnDate === null) {
|
||
continue;
|
||
}
|
||
|
||
// L'API donne la date de reprise. PTA utilise des bornes inclusives,
|
||
// donc le dernier jour de vacances est la veille de la reprise.
|
||
$end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d');
|
||
if ($end < $start) {
|
||
$end = $start;
|
||
}
|
||
|
||
$zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? '')));
|
||
$recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear));
|
||
|
||
$key = $description . '|' . $start . '|' . $end;
|
||
$periods[$key] = [
|
||
'libelle' => $description,
|
||
'date_debut' => $start,
|
||
'date_fin' => $end,
|
||
'date_reprise_api' => $returnDate,
|
||
'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear,
|
||
'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy,
|
||
'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''),
|
||
'source' => 'DATA_GOUV',
|
||
];
|
||
}
|
||
|
||
$periods = array_values($periods);
|
||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||
return $periods;
|
||
}
|
||
|
||
/**
|
||
* Reconnaît une période de vacances à partir de sa description.
|
||
*
|
||
* Filtrage par MOTS-CLÉS faute de champ de type dans le jeu de données. Les
|
||
* rentrées et pré-rentrées sont exclues explicitement : ce sont des
|
||
* événements ponctuels, pas des périodes. « été austral » et « hiver
|
||
* austral » couvrent les académies d'outre-mer.
|
||
*
|
||
* Une description non reconnue est SILENCIEUSEMENT ignorée : un libellé
|
||
* inédit peut donc faire disparaître une période, à surveiller si des
|
||
* vacances manquent à l'appel.
|
||
*/
|
||
private function isVacationDescription(string $description): bool
|
||
{
|
||
$normalized = $this->normalizeText($description);
|
||
if ($normalized === '') {
|
||
return false;
|
||
}
|
||
if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) {
|
||
return false;
|
||
}
|
||
|
||
foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) {
|
||
if (str_contains($normalized, $keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Graphies alternatives d'un nom d'académie à essayer successivement.
|
||
*
|
||
* Le portail n'est pas constant : « Nancy-Metz », « Nancy Metz »,
|
||
* « Orléans-Tours » ou « Orleans-Tours » selon les enregistrements, avec
|
||
* parfois un tiret cadratin plutôt qu'un trait d'union. On essaie donc
|
||
* l'original, les tirets uniformisés, la version sans accents, et les
|
||
* variantes à espaces.
|
||
*
|
||
* @return array<int, string> Sans doublon, dans l'ordre d'essai.
|
||
*/
|
||
private function academyVariants(string $academy): array
|
||
{
|
||
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
|
||
$variants = [$academy];
|
||
$variants[] = str_replace(['–', '—', '‑'], '-', $academy);
|
||
$variants[] = $this->stripAccents($academy);
|
||
$variants[] = str_replace('-', ' ', $academy);
|
||
$variants[] = str_replace('-', ' ', $this->stripAccents($academy));
|
||
|
||
return array_values(array_unique(array_filter(array_map('trim', $variants))));
|
||
}
|
||
|
||
/**
|
||
* Zone de vacances d'une académie, ou null si elle est inconnue de la table
|
||
* (outre-mer, ou nom mal orthographié).
|
||
*/
|
||
private function zoneForAcademy(string $academy): ?string
|
||
{
|
||
$key = $this->normalizeAcademy($academy);
|
||
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Effectue la requête HTTP et décode le JSON.
|
||
*
|
||
* Deux implémentations : cURL quand l'extension est présente, sinon
|
||
* file_get_contents() avec un contexte de flux. Ce second chemin ne
|
||
* fonctionne que si `allow_url_fopen` est actif, d'où le message explicite
|
||
* en cas d'échec — c'est la panne la plus fréquente sur un hébergement
|
||
* mutualisé.
|
||
*
|
||
* Délais volontairement courts (8 s de connexion, 20 s au total) : l'appel
|
||
* est SYNCHRONE dans une requête web, l'utilisateur attend devant son écran.
|
||
*
|
||
* `ignore_errors` et CURLOPT_RETURNTRANSFER permettent de lire le corps même
|
||
* sur un code d'erreur, mais tout code >= 400 est traité comme un échec.
|
||
*
|
||
* @return array<mixed> Charge utile décodée.
|
||
*
|
||
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
|
||
*/
|
||
private function fetchJson(string $url): array
|
||
{
|
||
$body = false;
|
||
$status = 0;
|
||
|
||
if (function_exists('curl_init')) {
|
||
$curl = curl_init($url);
|
||
if ($curl === false) {
|
||
throw new RuntimeException('Impossible d’initialiser cURL.');
|
||
}
|
||
curl_setopt_array($curl, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_CONNECTTIMEOUT => 8,
|
||
CURLOPT_TIMEOUT => 20,
|
||
CURLOPT_ENCODING => '',
|
||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'],
|
||
]);
|
||
$body = curl_exec($curl);
|
||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||
$error = curl_error($curl);
|
||
curl_close($curl);
|
||
if ($body === false) {
|
||
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
|
||
}
|
||
} else {
|
||
$context = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'timeout' => 20,
|
||
'ignore_errors' => true,
|
||
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n",
|
||
],
|
||
]);
|
||
$body = @file_get_contents($url, false, $context);
|
||
if ($body === false) {
|
||
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
|
||
}
|
||
foreach ($http_response_header ?? [] as $header) {
|
||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
|
||
$status = (int) $matches[1];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($status >= 400) {
|
||
throw new RuntimeException('L’API a répondu avec le code HTTP ' . $status . '.');
|
||
}
|
||
|
||
$data = json_decode((string) $body, true);
|
||
if (!is_array($data)) {
|
||
throw new RuntimeException('Le contenu reçu depuis l’API n’est pas un JSON valide.');
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* Échappe une valeur pour la clause `where` de l'API Explore, dont la
|
||
* syntaxe est proche du SQL : les guillemets et les barres obliques
|
||
* inverses doivent être protégés pour ne pas altérer la requête distante.
|
||
*/
|
||
private function quoteWhereValue(string $value): string
|
||
{
|
||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||
}
|
||
|
||
/**
|
||
* Extrait la partie date d'un horodatage ISO.
|
||
* L'API renvoie tantôt « 2026-02-21 », tantôt
|
||
* « 2026-02-21T23:00:00+00:00 » ; seule la date nous intéresse.
|
||
*
|
||
* @return string|null null si aucune date n'est reconnaissable.
|
||
*/
|
||
private function dateOnly(string $value): ?string
|
||
{
|
||
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
|
||
return null;
|
||
}
|
||
return $matches[1];
|
||
}
|
||
|
||
/**
|
||
* Ramène une valeur d'API à une chaîne.
|
||
* Certains champs (zones, population) sont tantôt scalaires, tantôt des
|
||
* tableaux selon la version de l'API ; les tableaux sont aplatis.
|
||
*/
|
||
private function stringValue(mixed $value): string
|
||
{
|
||
if (is_array($value)) {
|
||
return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value));
|
||
}
|
||
return (string) $value;
|
||
}
|
||
|
||
|
||
/**
|
||
* Vrai si l'enregistrement concerne les élèves.
|
||
*
|
||
* Acceptent : la valeur vide, « - », « Élèves », « Tous », « Tout public ».
|
||
* Les vacances communes sont en effet publiées avec une population « - » ;
|
||
* ne retenir que « Élèves » les faisait disparaître du résultat.
|
||
*/
|
||
private function isStudentPopulation(mixed $value): bool
|
||
{
|
||
$values = is_array($value) ? $value : [$value];
|
||
foreach ($values as $item) {
|
||
$population = $this->normalizeText((string) $item);
|
||
if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Forme canonique d'un nom d'académie pour comparaison : tirets et
|
||
* soulignés deviennent des espaces, accents et casse neutralisés.
|
||
* « Nancy-Metz », « nancy metz » et « NANCY_METZ » se rejoignent ainsi.
|
||
*/
|
||
private function normalizeAcademy(string $value): string
|
||
{
|
||
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
|
||
return $this->normalizeText($value);
|
||
}
|
||
|
||
/**
|
||
* Forme canonique d'un texte : sans accent, en minuscules, réduit aux
|
||
* lettres, chiffres et tirets, espaces compactés.
|
||
*/
|
||
private function normalizeText(string $value): string
|
||
{
|
||
$value = $this->stripAccents($value);
|
||
$value = str_replace(['–', '—', '‑', '_'], '-', $value);
|
||
$value = strtolower($value);
|
||
$value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value;
|
||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||
return trim($value);
|
||
}
|
||
|
||
/**
|
||
* Retire les accents par une table de correspondance explicite.
|
||
*
|
||
* Table écrite à la main plutôt qu'iconv ou intl : ces extensions ne sont
|
||
* pas garanties partout, et leur comportement de translittération varie
|
||
* selon la locale du système. Une table donne un résultat identique
|
||
* quel que soit l'hébergement.
|
||
*/
|
||
private function stripAccents(string $value): string
|
||
{
|
||
return strtr($value, [
|
||
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
|
||
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
|
||
'Ç' => 'C', 'ç' => 'c',
|
||
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
|
||
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
|
||
'Ñ' => 'N', 'ñ' => 'n',
|
||
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
|
||
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
|
||
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
|
||
'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y',
|
||
'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae',
|
||
]);
|
||
}
|
||
}
|