> In-request cache */ protected static $runtimeCache = []; /** * Return events for one day (YYYY-MM-DD) from configured local ICS files. * * @param string $icsConfig Multiline list of local ICS file paths * @param string $dateIso * @return array */ public static function eventsForDate(string $icsConfig, string $dateIso): array { if (!ChronoID::isIsoDate($dateIso)) return []; $files = self::parseConfiguredFiles($icsConfig); if ($files === []) return []; $signature = self::buildSignature($files); if ($signature === '') return []; $cacheKey = $signature . '|' . $dateIso; if (isset(self::$runtimeCache[$cacheKey])) { return self::$runtimeCache[$cacheKey]; } $utc = new DateTimeZone('UTC'); $rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc); $rangeStart = $rangeStart->sub(new DateInterval('P1D')); $rangeEnd = $rangeStart->add(new DateInterval('P3D')); $events = []; $seen = []; foreach ($files as $file) { foreach (self::readEventsFromFile($file, $dateIso, $rangeStart, $rangeEnd) as $entry) { $dedupeKey = implode('|', [ (string)($entry['summary'] ?? ''), (string)($entry['time'] ?? ''), ((bool)($entry['allDay'] ?? false)) ? '1' : '0', ]); if (isset($seen[$dedupeKey])) continue; $seen[$dedupeKey] = true; $events[] = $entry; } } usort($events, static function (array $a, array $b): int { $aAllDay = (bool)($a['allDay'] ?? false); $bAllDay = (bool)($b['allDay'] ?? false); if ($aAllDay !== $bAllDay) { return $aAllDay ? -1 : 1; } $timeCmp = strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? '')); if ($timeCmp !== 0) return $timeCmp; return strcmp((string)($a['summary'] ?? ''), (string)($b['summary'] ?? '')); }); self::$runtimeCache[$cacheKey] = $events; return $events; } /** * @param string $icsConfig * @return string[] */ protected static function parseConfiguredFiles(string $icsConfig): array { $files = []; $lines = preg_split('/\r\n|\r|\n/', $icsConfig) ?: []; foreach ($lines as $line) { $line = trim((string)$line); if ($line === '') continue; if (str_starts_with($line, '#')) continue; $path = Path::cleanPath($line, false); if (!is_file($path) || !is_readable($path)) continue; $files[] = $path; } $files = array_values(array_unique($files)); sort($files, SORT_NATURAL | SORT_FLAG_CASE); return $files; } /** * Build signature from file path + mtime + size. * * @param string[] $files * @return string */ protected static function buildSignature(array $files): string { if ($files === []) return ''; $parts = []; foreach ($files as $file) { $mtime = @filemtime($file) ?: 0; $size = @filesize($file) ?: 0; $parts[] = $file . '|' . $mtime . '|' . $size; } return sha1(implode("\n", $parts)); } /** * Parse one ICS file and return normalized events for the target day. * * @param string $file * @param string $dateIso * @param DateTimeImmutable $rangeStart * @param DateTimeImmutable $rangeEnd * @return array */ protected static function readEventsFromFile( string $file, string $dateIso, DateTimeImmutable $rangeStart, DateTimeImmutable $rangeEnd ): array { $raw = @file_get_contents($file); if (!is_string($raw) || trim($raw) === '') return []; try { $component = Reader::read($raw, Reader::OPTION_FORGIVING); if (!($component instanceof VCalendar)) return []; $expanded = $component->expand($rangeStart, $rangeEnd); if (!($expanded instanceof VCalendar)) return []; return self::collectEventsFromCalendar($expanded, $dateIso); } catch (Throwable $e) { return []; } } /** * @param VCalendar $calendar * @param string $dateIso * @return array */ protected static function collectEventsFromCalendar( VCalendar $calendar, string $dateIso ): array { $result = []; $seen = []; foreach ($calendar->select('VEVENT') as $vevent) { if (!($vevent instanceof VEvent)) continue; $normalized = self::normalizeEventForDay($vevent, $dateIso); if ($normalized === null) continue; $dedupeKey = implode('|', [ (string)($normalized['uid'] ?? ''), (string)($normalized['rid'] ?? ''), (string)($normalized['start'] ?? ''), (string)($normalized['summary'] ?? ''), (string)($normalized['time'] ?? ''), ((bool)($normalized['allDay'] ?? false)) ? '1' : '0', ]); if (isset($seen[$dedupeKey])) continue; $seen[$dedupeKey] = true; $result[] = [ 'summary' => (string)$normalized['summary'], 'time' => (string)$normalized['time'], 'startIso' => (string)$normalized['start'], 'allDay' => (bool)$normalized['allDay'], ]; } return $result; } /** * Convert VEVENT to output item when it intersects the target day. * * @param VEvent $vevent * @param string $dateIso * @return array|null */ protected static function normalizeEventForDay( VEvent $vevent, string $dateIso ): ?array { if (!isset($vevent->DTSTART)) return null; if (!ChronoID::isIsoDate($dateIso)) return null; $isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE'; $start = self::toImmutableDateTime($vevent->DTSTART->getDateTime()); if ($start === null) return null; $end = null; if (isset($vevent->DTEND)) { $end = self::toImmutableDateTime($vevent->DTEND->getDateTime()); } elseif (isset($vevent->DURATION)) { try { $duration = $vevent->DURATION->getDateInterval(); if ($duration instanceof DateInterval) { $end = $start->add($duration); } } catch (Throwable $e) { $end = null; } } if ($end === null) { $end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start; } if ($end <= $start) { $end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start; } $eventTimezone = $start->getTimezone(); $dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone); $dayEnd = $dayStart->add(new DateInterval('P1D')); $intersects = ($start < $dayEnd) && ($end > $dayStart); if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) { $intersects = true; } if (!$intersects) return null; $summary = trim((string)($vevent->SUMMARY ?? '')); if ($summary === '') $summary = '(ohne Titel)'; $uid = trim((string)($vevent->UID ?? '')); $rid = ''; if (isset($vevent->{'RECURRENCE-ID'})) { $rid = trim((string)$vevent->{'RECURRENCE-ID'}); } return [ 'uid' => $uid, 'rid' => $rid, 'start' => $start->format(DateTimeInterface::ATOM), 'summary' => $summary, 'time' => $isAllDay ? '' : $start->format('H:i'), 'allDay' => $isAllDay, ]; } /** * @param DateTimeInterface $dateTime * @return DateTimeImmutable|null */ protected static function toImmutableDateTime(DateTimeInterface $dateTime): ?DateTimeImmutable { if ($dateTime instanceof DateTimeImmutable) return $dateTime; $immutable = DateTimeImmutable::createFromFormat('U', (string)$dateTime->getTimestamp()); if (!($immutable instanceof DateTimeImmutable)) return null; return $immutable->setTimezone($dateTime->getTimezone()); } }