Add the Chronological
This commit is contained in:
283
src/ChronologicalIcsEvents.php
Normal file
283
src/ChronologicalIcsEvents.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user