diff --git a/README.md b/README.md index 73f7b44..bda68a8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ luxtools provides DokuWiki syntax that: - Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions) - Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias - Adds a Page ID download link in the page info area to fetch a `.pagelink` file +- Renders a basic calendar widget with clickable day links to chronological pages It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used to deliver files and generate cached thumbnails. @@ -49,6 +50,13 @@ If you install this plugin manually, make sure it is installed in: If the folder is called differently, DokuWiki will not load it. +This plugin uses Composer dependencies shipped inside `vendor/`. +If dependencies are missing in your local checkout, run: + +```bash +php composer.phar install +``` + ## Project structure (developer notes) @@ -149,6 +157,20 @@ Key settings: URL of a local client service used by `{{open>...}}` and directory links. See luxtools-client. +- **image_base_path** + Base filesystem path used for chronological photo integration. + On canonical day pages (`chronological:YYYY:MM:DD`), files that start with + `YYYY-MM-DD` are listed automatically. + If a yearly subfolder exists (for example `.../2026/`), it is preferred. + +- **calendar_ics_files** + Local calendar `.ics` files (one absolute file path per line). + Events are parsed by `sabre/vobject` and shown on matching chronological day pages. + Recurrence and exclusions from the ICS are respected. For timed entries, the + page stores the original timestamp and renders the visible time in the + browser's local timezone. + Multi-day events appear on each overlapping day. + - **pagelink_search_depth** Maximum directory depth for `.pagelink` discovery under each configured root. `0` means only the root directory itself is checked. @@ -246,6 +268,38 @@ for example: {{directory>blobs/&recursive=1}} ``` +### 0.3) Calendar widget + +Render a basic monthly calendar that links each day to canonical chronological pages: + +``` +{{calendar>}} +{{calendar>2024-10}} +``` + +Notes: + +- `{{calendar>}}` renders the current month. +- `{{calendar>YYYY-MM}}` renders a specific month. +- Day links target `chronological:YYYY:MM:DD`. +- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`. +- Prev/next month buttons update the widget in-place without a full page reload. +- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node. + +### 0.4) Virtual chronological day pages + +When a canonical day page (for example `chronological:2026:02:13`) does not yet +exist, luxtools renders a virtual page in normal show mode instead of the +default "page does not exist" output. + +The virtual page includes: + +- a German-formatted heading (for example `Freitag, 13. Februar 2026`) +- matching local calendar events from configured `.ics` files (when available) +- matching day photos (via existing `{{images>...}}` rendering) when available + +The page is only created once you edit and save actual content. + ### 1) List files by glob pattern The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table: diff --git a/_test/ChronoIDTest.php b/_test/ChronoIDTest.php new file mode 100644 index 0000000..d15df81 --- /dev/null +++ b/_test/ChronoIDTest.php @@ -0,0 +1,138 @@ +assertBool(true, ChronoID::isIsoDate($date), 'Expected valid ISO date: ' . $date); + } + } + + public function testIsIsoDateInvalidCases(): void + { + $invalid = [ + '2023-02-29', + '2024-13-01', + '2024-00-10', + '24-10-2024', + '2024/10/24', + '2024-10-24 12:00:00', + '2024-10-24T12:00:00', + '0000-01-01', + ]; + + foreach ($invalid as $date) { + $this->assertBool(false, ChronoID::isIsoDate($date), 'Expected invalid ISO date: ' . $date); + } + } + + public function testDateToDayId(): void + { + $this->assertStringOrNull('chronological:2024:10:24', ChronoID::dateToDayId('2024-10-24'), 'dateToDayId failed'); + $this->assertStringOrNull('journal:chrono:2024:10:24', ChronoID::dateToDayId('2024-10-24', 'journal:chrono'), 'dateToDayId with custom namespace failed'); + + $this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24T12:00:00'), 'datetime should be rejected'); + $this->assertStringOrNull(null, ChronoID::dateToDayId('2024-13-01'), 'invalid month should be rejected'); + $this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', ''), 'empty namespace should be rejected'); + $this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', 'bad namespace!'), 'invalid namespace should be rejected'); + } + + public function testCanonicalIdChecks(): void + { + $this->assertBool(true, ChronoID::isDayId('chronological:2024:10:24'), 'valid day ID should be accepted'); + $this->assertBool(true, ChronoID::isMonthId('chronological:2024:10'), 'valid month ID should be accepted'); + $this->assertBool(true, ChronoID::isYearId('chronological:2024'), 'valid year ID should be accepted'); + + $this->assertBool(false, ChronoID::isDayId('2024:10:24'), 'missing namespace should be rejected as day ID'); + $this->assertBool(false, ChronoID::isDayId('chronological:2024-10-24'), 'hyphen date in ID should be rejected as day ID'); + $this->assertBool(false, ChronoID::isDayId('chronological:2023:02:29'), 'invalid Gregorian day should be rejected'); + + $this->assertBool(false, ChronoID::isMonthId('chronological:2024:13'), 'invalid month 13 should be rejected'); + $this->assertBool(false, ChronoID::isMonthId('chronological:2024:00'), 'invalid month 00 should be rejected'); + $this->assertBool(false, ChronoID::isMonthId('chronological:2024-10'), 'invalid month format should be rejected'); + + $this->assertBool(false, ChronoID::isYearId('chronological:0000'), 'year 0000 should be rejected'); + $this->assertBool(false, ChronoID::isYearId('chronological:24'), 'short year should be rejected'); + $this->assertBool(false, ChronoID::isYearId('chronological:2024:10'), 'month ID should not pass as year ID'); + } + + public function testConversions(): void + { + $this->assertStringOrNull('chronological:2024:10', ChronoID::dayIdToMonthId('chronological:2024:10:24'), 'dayIdToMonthId failed'); + $this->assertStringOrNull('chronological:2024', ChronoID::monthIdToYearId('chronological:2024:10'), 'monthIdToYearId failed'); + + $this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:13:24'), 'invalid day ID should map to null month ID'); + $this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:10'), 'month ID should not map via dayIdToMonthId'); + $this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:13'), 'invalid month ID should map to null year ID'); + $this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:10:24'), 'day ID should not map via monthIdToYearId'); + } + + /** + * Integration-style smoke test for canonical ID matrix acceptance/rejection. + */ + public function testCanonicalPageIdSmokeMatrix(): void + { + $accepted = [ + ['day', 'chronological:2024:10:24'], + ['month', 'chronological:2024:10'], + ['year', 'chronological:2024'], + ]; + + foreach ($accepted as [$kind, $id]) { + if ($kind === 'day') { + $this->assertBool(true, ChronoID::isDayId($id), 'Expected accepted day ID: ' . $id); + } elseif ($kind === 'month') { + $this->assertBool(true, ChronoID::isMonthId($id), 'Expected accepted month ID: ' . $id); + } else { + $this->assertBool(true, ChronoID::isYearId($id), 'Expected accepted year ID: ' . $id); + } + } + + $rejected = [ + '2024:10:24', + 'chronological:2024-10-24', + 'chronological:2024:13:01', + 'chronological:2024:00', + 'chronological:0000', + ]; + + foreach ($rejected as $id) { + $this->assertBool(false, ChronoID::isDayId($id), 'Unexpected day ID acceptance: ' . $id); + $this->assertBool(false, ChronoID::isMonthId($id), 'Unexpected month ID acceptance: ' . $id); + $this->assertBool(false, ChronoID::isYearId($id), 'Unexpected year ID acceptance: ' . $id); + } + } +} diff --git a/_test/ChronologicalDateAutoLinkerTest.php b/_test/ChronologicalDateAutoLinkerTest.php new file mode 100644 index 0000000..93744d0 --- /dev/null +++ b/_test/ChronologicalDateAutoLinkerTest.php @@ -0,0 +1,42 @@ +Meeting on 2024-10-24

'; + $out = ChronologicalDateAutoLinker::linkHtml($html); + + $decoded = urldecode($out); + if (strpos($decoded, 'chronological:2024:10:24') === false) { + throw new \Exception('Expected canonical link target not found'); + } + } + + public function testSkipsCodeContent(): void + { + $html = '

Outside 2024-10-25

Inside 2024-10-24'; + $out = ChronologicalDateAutoLinker::linkHtml($html); + + $decoded = urldecode($out); + if (strpos($decoded, 'chronological:2024:10:25') === false) { + throw new \Exception('Expected outside date link not found'); + } + if (strpos($decoded, 'chronological:2024:10:24') !== false) { + throw new \Exception('Date inside code block should not be auto-linked'); + } + } +} diff --git a/_test/ChronologicalDayTemplateTest.php b/_test/ChronologicalDayTemplateTest.php new file mode 100644 index 0000000..9c5ff1a --- /dev/null +++ b/_test/ChronologicalDayTemplateTest.php @@ -0,0 +1,37 @@ + (string)$e['summary'], $events); + if (!in_array('Ganztag Event', $summaries, true)) { + throw new \Exception('Missing all-day event summary'); + } + if (!in_array('Termin A', $summaries, true)) { + throw new \Exception('Missing timed event summary'); + } + + $timed = null; + foreach ($events as $event) { + if ((string)($event['summary'] ?? '') === 'Termin A') { + $timed = $event; + break; + } + } + + if (!is_array($timed)) { + throw new \Exception('Timed event payload missing'); + } + if (trim((string)($timed['startIso'] ?? '')) === '') { + throw new \Exception('Timed event should expose startIso for client-side timezone conversion'); + } + } + + public function testEventsForDateReadsMultipleConfiguredFiles(): void + { + $dir = TMP_DIR . '/chrono_ics/' . uniqid('multi_', true); + @mkdir($dir, 0777, true); + $ics1 = $dir . '/one.ics'; + $ics2 = $dir . '/two.ics'; + + @file_put_contents($ics1, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T100000\nSUMMARY:Eins\nEND:VEVENT\nEND:VCALENDAR\n"); + @file_put_contents($ics2, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T110000\nSUMMARY:Zwei\nEND:VEVENT\nEND:VCALENDAR\n"); + + $config = $ics1 . "\n" . $ics2; + $events = ChronologicalIcsEvents::eventsForDate($config, '2026-02-18'); + + if (count($events) !== 2) { + throw new \Exception('Expected 2 events from two files, got ' . count($events)); + } + } + + public function testEventsForDateSupportsWeeklyRecurrence(): void + { + $dir = TMP_DIR . '/chrono_ics/' . uniqid('rrule_', true); + @mkdir($dir, 0777, true); + $ics = $dir . '/recurring.ics'; + + $content = "BEGIN:VCALENDAR\n" + . "BEGIN:VEVENT\n" + . "UID:weekly-1\n" + . "DTSTART:20260205T090000\n" + . "RRULE:FREQ=WEEKLY;INTERVAL=1\n" + . "SUMMARY:Wiederkehrender Termin\n" + . "END:VEVENT\n" + . "END:VCALENDAR\n"; + @file_put_contents($ics, $content); + + $events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12'); + if (count($events) < 1) { + throw new \Exception('Expected recurring event on 2026-02-12, got none'); + } + + $summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events); + if (!in_array('Wiederkehrender Termin', $summaries, true)) { + throw new \Exception('Recurring summary not found on matching date'); + } + } + + public function testEventsForDateRespectsExdateForRecurringEvent(): void + { + $dir = TMP_DIR . '/chrono_ics/' . uniqid('exdate_', true); + @mkdir($dir, 0777, true); + $ics = $dir . '/recurring-exdate.ics'; + + $content = "BEGIN:VCALENDAR\n" + . "BEGIN:VEVENT\n" + . "UID:weekly-2\n" + . "DTSTART:20260205T090000\n" + . "RRULE:FREQ=WEEKLY;COUNT=4\n" + . "EXDATE:20260212T090000\n" + . "SUMMARY:Termin mit Ausnahme\n" + . "END:VEVENT\n" + . "END:VCALENDAR\n"; + @file_put_contents($ics, $content); + + $events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12'); + $summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events); + if (in_array('Termin mit Ausnahme', $summaries, true)) { + throw new \Exception('Recurring event with EXDATE should not appear on excluded day'); + } + } + + public function testEventsForDateKeepsUtcDateAndTimeAsIs(): void + { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('Europe/Berlin'); + + try { + $dir = TMP_DIR . '/chrono_ics/' . uniqid('tz_', true); + @mkdir($dir, 0777, true); + $ics = $dir . '/timezone.ics'; + + $content = "BEGIN:VCALENDAR\n" + . "BEGIN:VEVENT\n" + . "UID:utc-shift\n" + . "DTSTART:20260216T233000Z\n" + . "SUMMARY:UTC Spaet\n" + . "END:VEVENT\n" + . "END:VCALENDAR\n"; + @file_put_contents($ics, $content); + + $eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16'); + $eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17'); + + $summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16); + $summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17); + + if (!in_array('UTC Spaet', $summaries16, true)) { + throw new \Exception('UTC event should stay on its own UTC date'); + } + if (in_array('UTC Spaet', $summaries17, true)) { + throw new \Exception('UTC event should not be shifted to next day by server timezone'); + } + + $utcEvent = null; + foreach ($eventsOn16 as $entry) { + if ((string)($entry['summary'] ?? '') === 'UTC Spaet') { + $utcEvent = $entry; + break; + } + } + if (!is_array($utcEvent)) { + throw new \Exception('UTC event payload missing after day match'); + } + if ((string)($utcEvent['time'] ?? '') !== '23:30') { + throw new \Exception('UTC event time should remain unchanged (expected 23:30)'); + } + } finally { + date_default_timezone_set($previousTimezone); + } + } + + public function testEventsForDateShowsMultiDayAllDayEventOnOverlappingDays(): void + { + $dir = TMP_DIR . '/chrono_ics/' . uniqid('multiday_', true); + @mkdir($dir, 0777, true); + $ics = $dir . '/multiday.ics'; + + $content = "BEGIN:VCALENDAR\n" + . "BEGIN:VEVENT\n" + . "UID:multi-day-1\n" + . "DTSTART;VALUE=DATE:20260216\n" + . "DTEND;VALUE=DATE:20260218\n" + . "SUMMARY:Mehrtagesereignis\n" + . "END:VEVENT\n" + . "END:VCALENDAR\n"; + @file_put_contents($ics, $content); + + $eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16'); + $eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17'); + $eventsOn18 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-18'); + + $summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16); + $summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17); + $summaries18 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn18); + + if (!in_array('Mehrtagesereignis', $summaries16, true)) { + throw new \Exception('Multi-day all-day event should appear on start day'); + } + if (!in_array('Mehrtagesereignis', $summaries17, true)) { + throw new \Exception('Multi-day all-day event should appear on overlapping day'); + } + if (in_array('Mehrtagesereignis', $summaries18, true)) { + throw new \Exception('Multi-day all-day event should respect exclusive DTEND day'); + } + } +} diff --git a/_test/SyntaxTest.php b/_test/SyntaxTest.php index ceee6d4..b00c4bf 100644 --- a/_test/SyntaxTest.php +++ b/_test/SyntaxTest.php @@ -404,4 +404,121 @@ class plugin_luxtools_test extends DokuWikiTest // Directory row should trigger the same behaviour as {{open>...}} for that folder $this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml); } + + /** + * Strict ISO dates in plain text should be auto-linked to canonical day IDs. + */ + public function test_auto_link_iso_date_plain_text() + { + $instructions = p_get_instructions('Meeting with John on 2024-10-24.'); + $xhtml = p_render('xhtml', $instructions, $info); + + if (strpos($xhtml, '>2024-10-24') === false) { + throw new \Exception('Auto-link text for 2024-10-24 not found'); + } + + if (strpos(urldecode($xhtml), 'chronological:2024:10:24') === false) { + throw new \Exception('Auto-link target chronological:2024:10:24 not found'); + } + } + + /** + * Auto-linking must not run inside code blocks. + */ + public function test_auto_link_skips_code_blocks() + { + $syntax = 'Outside date 2024-10-25.' . "\n\n" . 'Inside code 2024-10-24'; + $instructions = p_get_instructions($syntax); + $xhtml = p_render('xhtml', $instructions, $info); + + if (strpos($xhtml, '>2024-10-25') === false) { + throw new \Exception('Outside date 2024-10-25 was not auto-linked'); + } + + if (strpos(urldecode($xhtml), 'chronological:2024:10:25') === false) { + throw new \Exception('Outside auto-link target chronological:2024:10:25 not found'); + } + + if (strpos(urldecode($xhtml), 'chronological:2024:10:24') !== false) { + throw new \Exception('Date inside code block was incorrectly auto-linked'); + } + + if (strpos($xhtml, 'Inside code 2024-10-24') === false) { + throw new \Exception('Code block content was unexpectedly altered'); + } + } + + /** + * Calendar widget should render links to canonical day IDs. + */ + public function test_calendar_widget_links_canonical_day_ids() + { + $instructions = p_get_instructions('{{calendar>2024-10}}'); + $xhtml = p_render('xhtml', $instructions, $info); + + if (strpos($xhtml, 'luxtools-calendar') === false) { + throw new \Exception('Calendar container not rendered'); + } + + $decoded = urldecode($xhtml); + if (strpos($decoded, 'chronological:2024:10:01') === false) { + throw new \Exception('Expected canonical day link for 2024-10-01 not found'); + } + if (strpos($decoded, 'chronological:2024:10:31') === false) { + throw new \Exception('Expected canonical day link for 2024-10-31 not found'); + } + + if (strpos($decoded, 'chronological:2024:10') === false) { + throw new \Exception('Expected month link chronological:2024:10 not found in header'); + } + if (strpos($decoded, 'chronological:2024') === false) { + throw new \Exception('Expected year link chronological:2024 not found in header'); + } + if (strpos($decoded, 'chronological:2024:09') === false) { + throw new \Exception('Expected previous month canonical ID chronological:2024:09 not found'); + } + if (strpos($decoded, 'chronological:2024:11') === false) { + throw new \Exception('Expected next month canonical ID chronological:2024:11 not found'); + } + + if (strpos($xhtml, 'luxtools-calendar-nav') === false) { + throw new \Exception('Calendar navigation container not rendered'); + } + + if (strpos($xhtml, 'luxtools-calendar-nav-button') === false) { + throw new \Exception('Calendar navigation buttons not rendered'); + } + + if (strpos($xhtml, 'data-luxtools-calendar="1"') === false) { + throw new \Exception('Calendar JS state attribute not rendered'); + } + if (strpos($xhtml, 'data-luxtools-ajax-url=') === false) { + throw new \Exception('Calendar AJAX endpoint metadata not rendered'); + } + + if (strpos($xhtml, 'luxtools-calendar-day') === false || strpos($xhtml, 'register_hook( + "RENDERER_CONTENT_POSTPROCESS", + "BEFORE", + $this, + "autoLinkChronologicalDates", + ); + $controller->register_hook( + "RENDERER_CONTENT_POSTPROCESS", + "BEFORE", + $this, + "appendChronologicalDayEvents", + ); + $controller->register_hook( + "RENDERER_CONTENT_POSTPROCESS", + "BEFORE", + $this, + "appendChronologicalDayPhotos", + ); + $controller->register_hook( + "COMMON_PAGETPL_LOAD", + "BEFORE", + $this, + "prefillChronologicalDayTemplate", + ); + $controller->register_hook( + "TPL_ACT_RENDER", + "BEFORE", + $this, + "renderVirtualChronologicalDayPage", + ); $controller->register_hook( "CSS_STYLES_INCLUDED", "BEFORE", $this, "addTemporaryInputStyles", ); + $controller->register_hook( + "AJAX_CALL_UNKNOWN", + "BEFORE", + $this, + "handleCalendarWidgetAjax", + ); $controller->register_hook( "TOOLBAR_DEFINE", "AFTER", @@ -52,6 +96,7 @@ class action_plugin_luxtools extends ActionPlugin "date-fix.js", "page-link.js", "linkfavicon.js", + "calendar-widget.js", "main.js", ]; @@ -63,6 +108,46 @@ class action_plugin_luxtools extends ActionPlugin } } + /** + * Serve server-rendered calendar widget HTML for month navigation. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function handleCalendarWidgetAjax(Event $event, $param) + { + if ($event->data !== 'luxtools_calendar_month') return; + + $event->preventDefault(); + $event->stopPropagation(); + + global $INPUT; + + $year = (int)$INPUT->int('year'); + $month = (int)$INPUT->int('month'); + $baseNs = trim((string)$INPUT->str('base')); + if ($baseNs === '') { + $baseNs = 'chronological'; + } + + if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) { + http_status(400); + echo 'Invalid month'; + return; + } + + $html = ChronologicalCalendarWidget::render($year, $month, $baseNs); + if ($html === '') { + http_status(500); + echo 'Calendar rendering failed'; + return; + } + + header('Content-Type: text/html; charset=utf-8'); + echo $html; + } + /** * Include temporary global input styling via css.php so @ini_* placeholders resolve. * @@ -84,6 +169,378 @@ class action_plugin_luxtools extends ActionPlugin $event->data['files'][DOKU_PLUGIN . $plugin . '/temp-input-colors.css'] = DOKU_BASE . 'lib/plugins/' . $plugin . '/'; } + /** + * Auto-link strict ISO dates (YYYY-MM-DD) in rendered XHTML text nodes. + * + * Excludes content inside tags where links should not be altered. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function autoLinkChronologicalDates(Event $event, $param) + { + if (!is_array($event->data)) return; + + $mode = (string)($event->data[0] ?? ''); + if ($mode !== 'xhtml') return; + + $doc = $event->data[1] ?? null; + if (!is_string($doc) || $doc === '') return; + if (!preg_match('/\d{4}-\d{2}-\d{2}/', $doc)) return; + + $event->data[1] = ChronologicalDateAutoLinker::linkHtml($doc); + } + + /** + * Prefill new chronological day pages with a German date headline. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function prefillChronologicalDayTemplate(Event $event, $param) + { + if (!is_array($event->data)) return; + + $id = (string)($event->data['id'] ?? ''); + if ($id === '') return; + + if (function_exists('cleanID')) { + $id = (string)cleanID($id); + } + if ($id === '') return; + if (!ChronoID::isDayId($id)) return; + + $template = ChronologicalDayTemplate::buildForDayId($id); + if ($template === null || $template === '') return; + + $event->data['tpl'] = $template; + $event->data['tplfile'] = ''; + $event->data['doreplace'] = false; + } + + /** + * Append matching date-prefixed photos to chronological day page output. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function appendChronologicalDayPhotos(Event $event, $param) + { + if (self::$internalRenderInProgress) return; + if (!is_array($event->data)) return; + + $mode = (string)($event->data[0] ?? ''); + if ($mode !== 'xhtml') return; + + global $ACT; + if (!is_string($ACT) || $ACT !== 'show') return; + + $doc = $event->data[1] ?? null; + if (!is_string($doc)) return; + if (str_contains($doc, 'luxtools-chronological-photos')) return; + + global $ID; + $id = is_string($ID) ? $ID : ''; + if ($id === '') return; + if (function_exists('cleanID')) { + $id = (string)cleanID($id); + } + if ($id === '') return; + + $parts = ChronoID::parseDayId($id); + if ($parts === null) return; + + if (!function_exists('page_exists') || !page_exists($id)) return; + + $basePath = trim((string)$this->getConf('image_base_path')); + if ($basePath === '') return; + + $dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']); + if (!$this->hasAnyChronologicalPhotos($dateIso)) return; + + $photosHtml = $this->renderChronologicalPhotosMacro($dateIso); + if ($photosHtml === '') return; + + $event->data[1] = $doc . $photosHtml; + } + + /** + * Append local calendar events to existing chronological day pages. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function appendChronologicalDayEvents(Event $event, $param) + { + static $appendInProgress = false; + if ($appendInProgress) return; + if (self::$internalRenderInProgress) return; + + if (!is_array($event->data)) return; + + $mode = (string)($event->data[0] ?? ''); + if ($mode !== 'xhtml') return; + + global $ACT; + if (!is_string($ACT) || $ACT !== 'show') return; + + $doc = $event->data[1] ?? null; + if (!is_string($doc)) return; + if (str_contains($doc, 'luxtools-chronological-events')) return; + + global $ID; + $id = is_string($ID) ? $ID : ''; + if ($id === '') return; + if (function_exists('cleanID')) { + $id = (string)cleanID($id); + } + if ($id === '') return; + + $parts = ChronoID::parseDayId($id); + if ($parts === null) return; + if (!function_exists('page_exists') || !page_exists($id)) return; + + $dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']); + $appendInProgress = true; + try { + $eventsHtml = $this->renderChronologicalEventsHtml($dateIso); + } finally { + $appendInProgress = false; + } + if ($eventsHtml === '') return; + + $event->data[1] = $doc . $eventsHtml; + } + + /** + * Render chronological day photos using existing {{images>...}} syntax. + * + * @param string $dateIso + * @return string + */ + protected function renderChronologicalPhotosMacro(string $dateIso): string + { + $syntax = $this->buildChronologicalImagesSyntax($dateIso); + if ($syntax === '') return ''; + + if (self::$internalRenderInProgress) return ''; + self::$internalRenderInProgress = true; + + try { + $info = ['cache' => false]; + $instructions = p_get_instructions($syntax); + $galleryHtml = (string)p_render('xhtml', $instructions, $info); + } finally { + self::$internalRenderInProgress = false; + } + + if ($galleryHtml === '') return ''; + + $title = (string)$this->getLang('chronological_photos_title'); + if ($title === '') $title = 'Photos'; + + return '
' + . '

' . hsc($title) . '

' + . $galleryHtml + . '
'; + } + + /** + * Build {{images>...}} syntax for a given day. + * + * @param string $dateIso + * @return string + */ + protected function buildChronologicalImagesSyntax(string $dateIso): string + { + $basePath = trim((string)$this->getConf('image_base_path')); + if ($basePath === '') return ''; + + $base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath); + if (!is_dir($base) || !is_readable($base)) return ''; + + $yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/'; + $targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base; + + return '{{images>' . $targetDir . $dateIso . '*&recursive=0}}'; + } + + /** + * Render a virtual day page for missing chronological day IDs. + * + * Shows a German date heading and existing day photos (if any) without creating the page. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function renderVirtualChronologicalDayPage(Event $event, $param) + { + if (!is_string($event->data) || $event->data !== 'show') return; + + global $ID; + $id = is_string($ID) ? $ID : ''; + if ($id === '') return; + if (function_exists('cleanID')) { + $id = (string)cleanID($id); + } + if ($id === '') return; + + if (!ChronoID::isDayId($id)) return; + if (function_exists('page_exists') && page_exists($id)) return; + + $wikiText = ChronologicalDayTemplate::buildForDayId($id) ?? ''; + if ($wikiText === '') return; + + $parts = ChronoID::parseDayId($id); + $extraHtml = ''; + if ($parts !== null) { + $dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']); + + $eventsHtml = $this->renderChronologicalEventsHtml($dateIso); + if ($eventsHtml !== '') { + $extraHtml .= $eventsHtml; + } + + if ($this->hasAnyChronologicalPhotos($dateIso)) { + $photosHtml = $this->renderChronologicalPhotosMacro($dateIso); + if ($photosHtml !== '') { + $extraHtml .= $photosHtml; + } + } + } + + $editUrl = function_exists('wl') ? (string)wl($id, ['do' => 'edit']) : ''; + $createLinkHtml = ''; + if ($editUrl !== '') { + $label = (string)$this->getLang('btn_create'); + if ($label === '') $label = 'Create this page'; + $createLinkHtml = '

✎ ' . hsc($label) . '

'; + } + + $info = ['cache' => false]; + $instructions = p_get_instructions($wikiText); + $html = (string)p_render('xhtml', $instructions, $info); + + echo $html . $createLinkHtml . $extraHtml; + $event->preventDefault(); + $event->stopPropagation(); + } + + /** + * Check if there is at least one date-prefixed image for the given day. + * + * @param string $dateIso + * @return bool + */ + protected function hasAnyChronologicalPhotos(string $dateIso): bool + { + if (!ChronoID::isIsoDate($dateIso)) return false; + + $basePath = trim((string)$this->getConf('image_base_path')); + if ($basePath === '') return false; + + $base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath); + if (!is_dir($base) || !is_readable($base)) return false; + + $yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/'; + $targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base; + + $pattern = rtrim($targetDir, '/') . '/' . $dateIso . '*'; + $matches = glob($pattern) ?: []; + foreach ($matches as $match) { + if (!is_file($match)) continue; + $ext = strtolower(pathinfo($match, PATHINFO_EXTENSION)); + if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) { + return true; + } + } + + return false; + } + + /** + * Render local calendar events section for a given date. + * + * @param string $dateIso + * @return string + */ + protected function renderChronologicalEventsHtml(string $dateIso): string + { + $icsConfig = (string)$this->getConf('calendar_ics_files'); + if (trim($icsConfig) === '') return ''; + + $events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); + if ($events === []) return ''; + + $title = (string)$this->getLang('chronological_events_title'); + if ($title === '') $title = 'Events'; + + $items = ''; + foreach ($events as $entry) { + $summary = trim((string)($entry['summary'] ?? '')); + if ($summary === '') $summary = '(ohne Titel)'; + + $time = trim((string)($entry['time'] ?? '')); + $startIso = trim((string)($entry['startIso'] ?? '')); + $isAllDay = (bool)($entry['allDay'] ?? false); + + if ($isAllDay || $time === '') { + $items .= '
  • ' . hsc($summary) . '
  • '; + } else { + $timeHtml = ''; + $items .= '
  • ' . $timeHtml . ' - ' . hsc($summary) . '
  • '; + } + } + + if ($items === '') return ''; + $html = ''; + + return '
    ' + . '

    ' . hsc($title) . '

    ' + . $html + . '
    '; + } + + /** + * Build wiki bullet list for local calendar events. + * + * @param string $dateIso + * @return string + */ + protected function buildChronologicalEventsWiki(string $dateIso): string + { + $icsConfig = (string)$this->getConf('calendar_ics_files'); + if (trim($icsConfig) === '') return ''; + + $events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); + if ($events === []) return ''; + + $lines = []; + foreach ($events as $event) { + $summary = trim((string)($event['summary'] ?? '')); + if ($summary === '') $summary = '(ohne Titel)'; + $summary = str_replace(["\n", "\r"], ' ', $summary); + + $time = trim((string)($event['time'] ?? '')); + if ((bool)($event['allDay'] ?? false) || $time === '') { + $lines[] = ' * ' . $summary; + } else { + $lines[] = ' * ' . $time . ' - ' . $summary; + } + } + + return implode("\n", $lines); + } + /** * Add custom toolbar button for code blocks. * diff --git a/admin/main.php b/admin/main.php index c1ae652..e77f66a 100644 --- a/admin/main.php +++ b/admin/main.php @@ -28,6 +28,8 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin 'thumb_placeholder', 'gallery_thumb_scale', 'open_service_url', + 'image_base_path', + 'calendar_ics_files', 'pagelink_search_depth', ]; @@ -86,6 +88,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin $newConf['thumb_placeholder'] = $INPUT->str('thumb_placeholder'); $newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale'); $newConf['open_service_url'] = $INPUT->str('open_service_url'); + $newConf['image_base_path'] = $INPUT->str('image_base_path'); + + $icsFiles = $INPUT->str('calendar_ics_files'); + $icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles); + $newConf['calendar_ics_files'] = $icsFiles; $depth = (int)$INPUT->int('pagelink_search_depth'); if ($depth < 0) $depth = 0; @@ -228,6 +235,17 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin echo ''; echo '
    '; + // image_base_path + echo '
    '; + + // calendar_ics_files + $icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files'); + echo '
    '; + // pagelink_search_depth echo '