Files
luxtools-plugin/src/ChronologicalIcsEvents.php
2026-02-16 13:39:26 +01:00

284 lines
9.0 KiB
PHP

<?php
namespace dokuwiki\plugin\luxtools;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
use Throwable;
/**
* Read local ICS files using sabre/vobject and expose events for one day.
*/
class ChronologicalIcsEvents
{
/** @var array<string,array<int,array{summary:string,time:string,startIso:string,allDay:bool}>> 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<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
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<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
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<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
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<string,mixed>|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());
}
}