'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> */ 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> */ 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> * * @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 $parameters Paramètres de requête Explore. * * @return array 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> * * @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 $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> 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 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 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', ]); } }