[['params' => [...], 'value' => ...], ...]. */ function parse_ical_events(string $ical): array { $ical = str_replace(["\r\n", "\r"], "\n", $ical); $ical = preg_replace('/\n[ \t]/', '', $ical); $events = []; $current = null; foreach (explode("\n", $ical) as $line) { $line = rtrim($line); if ($line === 'BEGIN:VEVENT') { $current = []; } elseif ($line === 'END:VEVENT') { if ($current !== null) { $events[] = $current; } $current = null; } elseif ($current !== null && $line !== '') { // NOM[;PARAM=VAL...]:valeur (les valeurs de parametres peuvent // etre entre guillemets et contenir ':' ; on coupe sur le 1er // ':' hors guillemets) if (!preg_match('/^([A-Za-z0-9-]+)((?:;(?:[^:;"]|"[^"]*")*)*):(.*)$/s', $line, $m)) { continue; } $params = []; foreach (explode(';', ltrim($m[2], ';')) as $p) { if (strpos($p, '=') !== false) { [$k, $v] = explode('=', $p, 2); $params[strtoupper($k)] = trim($v, '"'); } } $current[strtoupper($m[1])][] = ['params' => $params, 'value' => $m[3]]; } } return $events; } /** Deséchappe une valeur TEXT iCal : \\ \, \; \n. */ function ical_unescape(string $text): string { return preg_replace_callback('/\\\\([nN,;\\\\])/', function ($m) { return ($m[1] === 'n' || $m[1] === 'N') ? "\n" : $m[1]; }, $text); } function ical_text(array $event, string $name): string { return isset($event[$name][0]) ? trim(ical_unescape($event[$name][0]['value'])) : ''; } /** Renvoie [DateTimeImmutable|null, bool $allDay] pour DTSTART/DTEND/CREATED. */ function ical_date(array $event, string $name): array { if (!isset($event[$name][0])) { return [null, false]; } $prop = $event[$name][0]; $value = trim($prop['value']); $display = new DateTimeZone(DISPLAY_TZ); try { if (($prop['params']['VALUE'] ?? '') === 'DATE' || preg_match('/^\d{8}$/', $value)) { return [new DateTimeImmutable($value, $display), true]; } if (substr($value, -1) === 'Z') { $dt = new DateTimeImmutable(substr($value, 0, -1), new DateTimeZone('UTC')); } else { $tz = new DateTimeZone($prop['params']['TZID'] ?? DISPLAY_TZ); $dt = new DateTimeImmutable($value, $tz); } return [$dt->setTimezone($display), false]; } catch (Exception $e) { return [null, false]; } } function format_event_dates(?DateTimeImmutable $start, ?DateTimeImmutable $end, bool $allDay): string { if (!$start) { return ''; } if ($allDay) { // En iCal, DTEND d'un evenement "journee entiere" est exclusif. $last = $end ? $end->modify('-1 day') : $start; if ($last <= $start) { return 'Le ' . $start->format('d/m/Y'); } return 'Du ' . $start->format('d/m/Y') . ' au ' . $last->format('d/m/Y'); } if (!$end || $end == $start) { return 'Le ' . $start->format('d/m/Y à H:i'); } if ($end->format('Y-m-d') === $start->format('Y-m-d')) { return 'Le ' . $start->format('d/m/Y') . ' de ' . $start->format('H:i') . ' à ' . $end->format('H:i'); } return 'Du ' . $start->format('d/m/Y à H:i') . ' au ' . $end->format('d/m/Y à H:i'); } /** Texte brut -> HTML : paragraphes sur lignes vides,
sur retours simples, URLs cliquables. */ function text_to_html(string $text): string { $html = ''; foreach (preg_split('/\n{2,}/', trim($text)) as $para) { $para = trim($para); if ($para === '') { continue; } $safe = htmlspecialchars($para, ENT_QUOTES, 'UTF-8'); $safe = preg_replace('#(https?://[^\s<]+)#', '$1', $safe); $html .= '

' . nl2br($safe, false) . '

'; } return $html; } function build_events_feed(): string { $ical = http_get(ICAL_URL, ['Accept: text/calendar, */*']); if (strpos($ical, 'BEGIN:VCALENDAR') === false) { throw new RuntimeException('Reponse inattendue : pas un calendrier iCal'); } $entries = []; $seen = []; foreach (parse_ical_events($ical) as $event) { [$start, $allDay] = ical_date($event, 'DTSTART'); [$end] = ical_date($event, 'DTEND'); [$created] = ical_date($event, 'CREATED'); $title = ical_text($event, 'SUMMARY') ?: '(sans titre)'; $uid = ical_text($event, 'UID') ?: $title . '-' . ($start ? $start->format('c') : ''); $url = ical_text($event, 'URL'); $location = ical_text($event, 'LOCATION'); $guid = $uid; if (isset($seen[$guid])) { // occurrence d'un evenement repete : meme UID $guid .= '-' . ($start ? $start->format('YmdHi') : count($seen)); } $seen[$guid] = true; $meta = format_event_dates($start, $end, $allDay); if ($location !== '') { $meta .= ($meta !== '' ? ' — ' : '') . 'Lieu : ' . $location; } $description = ($meta !== '' ? '

' . htmlspecialchars($meta, ENT_QUOTES, 'UTF-8') . '

' : '') . text_to_html(ical_text($event, 'DESCRIPTION')); $pub = $created ?? $start; $entries[] = [ 'sort' => $start ? $start->getTimestamp() : PHP_INT_MAX, 'item' => [ 'title' => $title, 'link' => $url !== '' ? $url : 'https://www.silly.be/agenda/', 'guid' => $guid, 'pubDate' => $pub ? $pub->format('c') : null, 'description' => $description, ], ]; } usort($entries, fn($a, $b) => $a['sort'] <=> $b['sort']); return build_rss( 'Silly.be - Agenda', 'https://www.silly.be/agenda/', 'Agenda / événements de la commune de Silly (Belgique)', array_column($entries, 'item'), AGENDA_SELF_URL ); } serve_cached_feed('agenda', 'build_events_feed', AGENDA_TTL_SECONDS);