Add the Chronological
This commit is contained in:
241
src/ChronoID.php
Normal file
241
src/ChronoID.php
Normal 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;
|
||||
}
|
||||
}
|
||||
143
src/ChronologicalCalendarWidget.php
Normal file
143
src/ChronologicalCalendarWidget.php
Normal 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;
|
||||
}
|
||||
}
|
||||
134
src/ChronologicalDateAutoLinker.php
Normal file
134
src/ChronologicalDateAutoLinker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/ChronologicalDayTemplate.php
Normal file
71
src/ChronologicalDayTemplate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
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