284 lines
9.0 KiB
PHP
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());
|
|
}
|
|
}
|