Add the Chronological

This commit is contained in:
2026-02-16 13:39:26 +01:00
parent c091ed1371
commit f1ac693fe8
162 changed files with 25868 additions and 1 deletions

241
src/ChronoID.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Helper for canonical chronological page IDs.
*
* Canonical structure:
* - Day: baseNs:YYYY:MM:DD
* - Month: baseNs:YYYY:MM
* - Year: baseNs:YYYY
*
* Date input format:
* - Strict ISO date only: YYYY-MM-DD
*/
class ChronoID
{
/**
* Check if a string is a strict ISO date and a valid Gregorian date.
*
* @param string $value
* @return bool
*/
public static function isIsoDate(string $value): bool
{
return self::parseIsoDate($value) !== null;
}
/**
* Convert YYYY-MM-DD to canonical day page ID.
*
* @param string $value Date in strict YYYY-MM-DD format
* @param string $baseNs Base namespace, default chronological
* @return string|null Canonical day ID or null on invalid input
*/
public static function dateToDayId(string $value, string $baseNs = 'chronological'): ?string
{
$parts = self::parseIsoDate($value);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
[$year, $month, $day] = $parts;
return sprintf('%s:%04d:%02d:%02d', $ns, $year, $month, $day);
}
/**
* Check whether a page ID is a canonical day ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isDayId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseDayId($id, $baseNs) !== null;
}
/**
* Check whether a page ID is a canonical month ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isMonthId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseMonthId($id, $baseNs) !== null;
}
/**
* Check whether a page ID is a canonical year ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isYearId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseYearId($id, $baseNs) !== null;
}
/**
* Convert canonical day ID to canonical month ID.
*
* @param string $dayId
* @param string $baseNs
* @return string|null
*/
public static function dayIdToMonthId(string $dayId, string $baseNs = 'chronological'): ?string
{
$parts = self::parseDayId($dayId, $baseNs);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
return sprintf('%s:%04d:%02d', $ns, $parts['year'], $parts['month']);
}
/**
* Convert canonical month ID to canonical year ID.
*
* @param string $monthId
* @param string $baseNs
* @return string|null
*/
public static function monthIdToYearId(string $monthId, string $baseNs = 'chronological'): ?string
{
$parts = self::parseMonthId($monthId, $baseNs);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
return sprintf('%s:%04d', $ns, $parts['year']);
}
/**
* Parse canonical day ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int,month:int,day:int}|null
*/
public static function parseDayId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2}):(\d{2})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
$month = (int)$matches[2];
$day = (int)$matches[3];
if ($year < 1) return null;
if (!checkdate($month, $day, $year)) return null;
return [
'year' => $year,
'month' => $month,
'day' => $day,
];
}
/**
* Parse canonical month ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int,month:int}|null
*/
public static function parseMonthId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
$month = (int)$matches[2];
if ($year < 1) return null;
if ($month < 1 || $month > 12) return null;
return [
'year' => $year,
'month' => $month,
];
}
/**
* Parse canonical year ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int}|null
*/
public static function parseYearId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
if ($year < 1) return null;
return ['year' => $year];
}
/**
* Parse strict ISO date YYYY-MM-DD.
*
* @param string $value
* @return int[]|null [year, month, day] or null
*/
protected static function parseIsoDate(string $value): ?array
{
$value = trim($value);
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
$year = (int)$matches[1];
$month = (int)$matches[2];
$day = (int)$matches[3];
if ($year < 1) return null;
if (!checkdate($month, $day, $year)) return null;
return [$year, $month, $day];
}
/**
* Normalize and validate base namespace.
*
* Allows one or more namespace segments with characters [a-z0-9_-].
*
* @param string $baseNs
* @return string|null
*/
protected static function normalizeBaseNs(string $baseNs): ?string
{
$baseNs = strtolower(trim($baseNs));
$baseNs = trim($baseNs, ':');
if ($baseNs === '') return null;
if (!preg_match('/^[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)*$/', $baseNs)) {
return null;
}
return $baseNs;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Render the chronological calendar widget markup.
*/
class ChronologicalCalendarWidget
{
/**
* Render full calendar widget HTML for one month.
*
* @param int $year
* @param int $month
* @param string $baseNs
* @return string
*/
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
{
if (!self::isValidMonth($year, $month)) return '';
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
$daysInMonth = (int)date('t', $firstDayTs);
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
$monthCursor = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (!($monthCursor instanceof \DateTimeImmutable)) return '';
$prevMonth = $monthCursor->sub(new \DateInterval('P1M'));
$nextMonth = $monthCursor->add(new \DateInterval('P1M'));
$monthStartDate = sprintf('%04d-%02d-01', $year, $month);
$monthDayId = ChronoID::dateToDayId($monthStartDate, $baseNs);
$monthId = $monthDayId !== null ? ChronoID::dayIdToMonthId($monthDayId, $baseNs) : null;
$yearId = $monthId !== null ? ChronoID::monthIdToYearId($monthId, $baseNs) : null;
$prevStartDate = $prevMonth->format('Y-m-d');
$prevDayId = ChronoID::dateToDayId($prevStartDate, $baseNs);
$prevMonthId = $prevDayId !== null ? ChronoID::dayIdToMonthId($prevDayId, $baseNs) : null;
$nextStartDate = $nextMonth->format('Y-m-d');
$nextDayId = ChronoID::dateToDayId($nextStartDate, $baseNs);
$nextMonthId = $nextDayId !== null ? ChronoID::dayIdToMonthId($nextDayId, $baseNs) : null;
$leadingEmpty = $firstWeekday - 1;
$totalCells = (int)ceil(($leadingEmpty + $daysInMonth) / 7) * 7;
$todayY = (int)date('Y');
$todayM = (int)date('m');
$todayD = (int)date('d');
$dayUrlTemplate = function_exists('wl') ? (string)wl('__LUXTOOLS_ID_RAW__') : '';
$monthUrlTemplate = $dayUrlTemplate;
$yearUrlTemplate = $dayUrlTemplate;
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$html = '<div class="luxtools-plugin luxtools-calendar" data-luxtools-calendar="1"'
. ' data-base-ns="' . hsc($baseNs) . '"'
. ' data-current-year="' . hsc((string)$year) . '"'
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
. ' data-prev-month-id="' . hsc((string)$prevMonthId) . '"'
. ' data-next-month-id="' . hsc((string)$nextMonthId) . '"'
. '>';
$html .= '<div class="luxtools-calendar-nav">';
$html .= '<div class="luxtools-calendar-nav-prev">';
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="-1" aria-label="Previous month">◀</button>';
$html .= '</div>';
$html .= '<div class="luxtools-calendar-title">';
$monthLabel = date('F', $firstDayTs);
if ($monthId !== null && function_exists('wl')) {
$html .= '<a class="luxtools-calendar-month-link" href="' . hsc((string)wl($monthId)) . '">' . hsc($monthLabel) . '</a>';
} else {
$html .= hsc($monthLabel);
}
$html .= ' ';
if ($yearId !== null && function_exists('wl')) {
$html .= '<a class="luxtools-calendar-year-link" href="' . hsc((string)wl($yearId)) . '">' . hsc((string)$year) . '</a>';
} else {
$html .= hsc((string)$year);
}
$html .= '</div>';
$html .= '<div class="luxtools-calendar-nav-next">';
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="1" aria-label="Next month">▶</button>';
$html .= '</div>';
$html .= '</div>';
$html .= '<table class="luxtools-calendar-table">';
$html .= '<thead><tr>';
foreach (['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as $weekday) {
$html .= '<th scope="col">' . hsc($weekday) . '</th>';
}
$html .= '</tr></thead><tbody>';
for ($cell = 0; $cell < $totalCells; $cell++) {
if ($cell % 7 === 0) $html .= '<tr>';
$dayNumber = $cell - $leadingEmpty + 1;
if ($dayNumber < 1 || $dayNumber > $daysInMonth) {
$html .= '<td class="luxtools-calendar-day luxtools-calendar-day-empty"></td>';
} else {
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
$dayId = ChronoID::dateToDayId($date, $baseNs);
$classes = 'luxtools-calendar-day';
if ($year === $todayY && $month === $todayM && $dayNumber === $todayD) {
$classes .= ' luxtools-calendar-day-today';
}
$html .= '<td class="' . hsc($classes) . '">';
if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
} else {
$html .= hsc((string)$dayNumber);
}
$html .= '</td>';
}
if ($cell % 7 === 6) $html .= '</tr>';
}
$html .= '</tbody></table></div>';
return $html;
}
/**
* @param int $year
* @param int $month
* @return bool
*/
public static function isValidMonth(int $year, int $month): bool
{
if ($year < 1) return false;
if ($month < 1 || $month > 12) return false;
return true;
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Auto-links strict ISO dates in rendered XHTML fragments.
*/
class ChronologicalDateAutoLinker
{
/** @var string[] Tags where auto-linking must be disabled */
protected static $blockedTags = [
'a',
'code',
'pre',
'script',
'style',
'textarea',
];
/**
* Link valid ISO dates in HTML text nodes while skipping blocked tags.
*
* @param string $html
* @return string
*/
public static function linkHtml(string $html): string
{
$parts = preg_split('/(<[^>]+>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if (!is_array($parts)) return $html;
$blocked = [];
foreach (self::$blockedTags as $tag) {
$blocked[$tag] = 0;
}
$out = '';
foreach ($parts as $part) {
if ($part === '') {
$out .= $part;
continue;
}
if (str_starts_with($part, '<')) {
self::updateBlockedTagCounters($part, $blocked);
$out .= $part;
continue;
}
if (self::isBlockedContext($blocked)) {
$out .= $part;
continue;
}
$out .= self::linkText($part);
}
return $out;
}
/**
* Link strict ISO dates in plain text.
*
* @param string $text
* @return string
*/
protected static function linkText(string $text): string
{
$replaced = preg_replace_callback(
'/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/',
static function (array $matches): string {
$date = (string)($matches[1] ?? '');
if ($date === '') return $matches[0];
$id = ChronoID::dateToDayId($date);
if ($id === null) return $matches[0];
if (function_exists('html_wikilink')) {
return (string)html_wikilink($id, $date);
}
if (function_exists('wl')) {
return '<a href="' . hsc((string)wl($id)) . '">' . hsc($date) . '</a>';
}
return $matches[0];
},
$text
);
return is_string($replaced) ? $replaced : $text;
}
/**
* Update blocked-tag counters while traversing HTML tokens.
*
* @param string $token
* @param array<string,int> $blocked
* @return void
*/
protected static function updateBlockedTagCounters(string $token, array &$blocked): void
{
if (!preg_match('/^<\s*(\/?)\s*([a-zA-Z0-9:_-]+)/', $token, $matches)) {
return;
}
$isClosing = $matches[1] === '/';
$tag = strtolower((string)$matches[2]);
if (!array_key_exists($tag, $blocked)) return;
if ($isClosing) {
if ($blocked[$tag] > 0) $blocked[$tag]--;
return;
}
$selfClosing = preg_match('/\/\s*>$/', $token) === 1;
if (!$selfClosing) {
$blocked[$tag]++;
}
}
/**
* Check if traversal is currently inside a blocked context.
*
* @param array<string,int> $blocked
* @return bool
*/
protected static function isBlockedContext(array $blocked): bool
{
foreach ($blocked as $count) {
if ($count > 0) return true;
}
return false;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Builds page template content for chronological day pages.
*/
class ChronologicalDayTemplate
{
/**
* Build a German date heading template for a canonical day ID.
*
* Example output:
* ====== Freitag, 13. Februar 2026 ======
*
* @param string $dayId
* @param string $baseNs
* @return string|null
*/
public static function buildForDayId(string $dayId, string $baseNs = 'chronological'): ?string
{
$parts = ChronoID::parseDayId($dayId, $baseNs);
if ($parts === null) return null;
$formatted = self::formatGermanDate($parts['year'], $parts['month'], $parts['day']);
return '====== ' . $formatted . " ======\n\n";
}
/**
* Format date with German day/month names and style:
* Freitag, 13. Februar 2026
*
* @param int $year
* @param int $month
* @param int $day
* @return string
*/
protected static function formatGermanDate(int $year, int $month, int $day): string
{
$weekdays = [
1 => 'Montag',
2 => 'Dienstag',
3 => 'Mittwoch',
4 => 'Donnerstag',
5 => 'Freitag',
6 => 'Samstag',
7 => 'Sonntag',
];
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
$weekdayIndex = (int)date('N', mktime(0, 0, 0, $month, $day, $year));
$weekday = $weekdays[$weekdayIndex] ?? '';
$monthName = $months[$month] ?? '';
return sprintf('%s, %d. %s %d', $weekday, $day, $monthName, $year);
}
}

View 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());
}
}