'Zone B', 'amiens' => 'Zone B', 'besancon' => 'Zone A', 'bordeaux' => 'Zone A', 'clermont-ferrand' => 'Zone A', 'creteil' => 'Zone C', 'dijon' => 'Zone A', 'grenoble' => 'Zone A', 'lille' => 'Zone B', 'limoges' => 'Zone A', 'lyon' => 'Zone A', 'montpellier' => 'Zone C', 'nancy-metz' => 'Zone B', 'nantes' => 'Zone B', 'nice' => 'Zone B', 'normandie' => 'Zone B', 'orleans-tours' => 'Zone B', 'paris' => 'Zone C', 'poitiers' => 'Zone A', 'reims' => 'Zone B', 'rennes' => 'Zone B', 'strasbourg' => 'Zone B', 'toulouse' => 'Zone C', 'versailles' => 'Zone C', ]; /** * Compatibilité avec le code existant : retourne uniquement les périodes. */ 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. * * @return array{periods: array, warnings: array, school_years: array} */ 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. */ public function preview(string $academy, string $schoolYear): array { return $this->previewSchoolYear($academy, $schoolYear); } 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 []; } 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.'); } 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 ))); } 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; } 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; } 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)))); } private function zoneForAcademy(string $academy): ?string { $key = $this->normalizeAcademy($academy); return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null; } 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; } private function quoteWhereValue(string $value): string { return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; } private function dateOnly(string $value): ?string { if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) { return null; } return $matches[1]; } 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; } 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; } private function normalizeAcademy(string $value): string { $value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value); return $this->normalizeText($value); } 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); } 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', ]); } }