From f1ac693fe8e1dd62f051db3889bec53131db73ba Mon Sep 17 00:00:00 2001
From: luxick
Date: Mon, 16 Feb 2026 13:39:26 +0100
Subject: [PATCH] Add the Chronological
---
README.md | 54 +
_test/ChronoIDTest.php | 138 ++
_test/ChronologicalDateAutoLinkerTest.php | 42 +
_test/ChronologicalDayTemplateTest.php | 37 +
_test/ChronologicalIcsEventsTest.php | 222 +++
_test/SyntaxTest.php | 117 ++
action.php | 459 ++++++-
admin/main.php | 18 +
autoload.php | 5 +
composer.json | 5 +
composer.lock | 252 ++++
conf/default.php | 6 +
js/calendar-widget.js | 129 ++
js/main.js | 38 +
lang/de/lang.php | 6 +
lang/en/lang.php | 7 +
src/ChronoID.php | 241 ++++
src/ChronologicalCalendarWidget.php | 143 ++
src/ChronologicalDateAutoLinker.php | 134 ++
src/ChronologicalDayTemplate.php | 71 +
src/ChronologicalIcsEvents.php | 283 ++++
style.css | 111 ++
syntax/calendar.php | 147 ++
vendor/autoload.php | 22 +
vendor/bin/generate_vcards | 119 ++
vendor/bin/vobject | 119 ++
vendor/composer/ClassLoader.php | 579 ++++++++
vendor/composer/InstalledVersions.php | 396 ++++++
vendor/composer/LICENSE | 21 +
vendor/composer/autoload_classmap.php | 10 +
vendor/composer/autoload_files.php | 12 +
vendor/composer/autoload_namespaces.php | 9 +
vendor/composer/autoload_psr4.php | 12 +
vendor/composer/autoload_real.php | 50 +
vendor/composer/autoload_static.php | 52 +
vendor/composer/installed.json | 248 ++++
vendor/composer/installed.php | 50 +
vendor/composer/platform_check.php | 25 +
vendor/sabre/uri/LICENSE | 27 +
vendor/sabre/uri/composer.json | 68 +
vendor/sabre/uri/lib/InvalidUriException.php | 19 +
vendor/sabre/uri/lib/Version.php | 20 +
vendor/sabre/uri/lib/functions.php | 425 ++++++
vendor/sabre/vobject/LICENSE | 27 +
vendor/sabre/vobject/README.md | 55 +
vendor/sabre/vobject/bin/bench.php | 12 +
.../vobject/bin/bench_freebusygenerator.php | 53 +
.../vobject/bin/bench_manipulatevcard.php | 64 +
.../sabre/vobject/bin/fetch_windows_zones.php | 48 +
vendor/sabre/vobject/bin/generate_vcards | 241 ++++
.../vobject/bin/generateicalendardata.php | 87 ++
vendor/sabre/vobject/bin/mergeduplicates.php | 160 +++
vendor/sabre/vobject/bin/rrulebench.php | 32 +
vendor/sabre/vobject/bin/vobject | 27 +
vendor/sabre/vobject/composer.json | 107 ++
.../vobject/lib/BirthdayCalendarGenerator.php | 172 +++
vendor/sabre/vobject/lib/Cli.php | 705 ++++++++++
vendor/sabre/vobject/lib/Component.php | 672 ++++++++++
.../sabre/vobject/lib/Component/Available.php | 123 ++
vendor/sabre/vobject/lib/Component/VAlarm.php | 138 ++
.../vobject/lib/Component/VAvailability.php | 149 +++
.../sabre/vobject/lib/Component/VCalendar.php | 528 ++++++++
vendor/sabre/vobject/lib/Component/VCard.php | 541 ++++++++
vendor/sabre/vobject/lib/Component/VEvent.php | 140 ++
.../sabre/vobject/lib/Component/VFreeBusy.php | 93 ++
.../sabre/vobject/lib/Component/VJournal.php | 101 ++
.../sabre/vobject/lib/Component/VTimeZone.php | 63 +
vendor/sabre/vobject/lib/Component/VTodo.php | 181 +++
vendor/sabre/vobject/lib/DateTimeParser.php | 560 ++++++++
vendor/sabre/vobject/lib/Document.php | 269 ++++
vendor/sabre/vobject/lib/ElementList.php | 52 +
vendor/sabre/vobject/lib/EofException.php | 15 +
vendor/sabre/vobject/lib/FreeBusyData.php | 185 +++
.../sabre/vobject/lib/FreeBusyGenerator.php | 549 ++++++++
vendor/sabre/vobject/lib/ITip/Broker.php | 1003 ++++++++++++++
.../sabre/vobject/lib/ITip/ITipException.php | 16 +
vendor/sabre/vobject/lib/ITip/Message.php | 136 ++
...SameOrganizerForAllComponentsException.php | 18 +
.../vobject/lib/InvalidDataException.php | 15 +
vendor/sabre/vobject/lib/Node.php | 256 ++++
.../sabre/vobject/lib/PHPUnitAssertions.php | 75 ++
vendor/sabre/vobject/lib/Parameter.php | 368 +++++
vendor/sabre/vobject/lib/ParseException.php | 14 +
vendor/sabre/vobject/lib/Parser/Json.php | 190 +++
vendor/sabre/vobject/lib/Parser/MimeDir.php | 710 ++++++++++
vendor/sabre/vobject/lib/Parser/Parser.php | 75 ++
vendor/sabre/vobject/lib/Parser/XML.php | 377 ++++++
.../lib/Parser/XML/Element/KeyValue.php | 63 +
vendor/sabre/vobject/lib/Property.php | 646 +++++++++
vendor/sabre/vobject/lib/Property/Binary.php | 109 ++
vendor/sabre/vobject/lib/Property/Boolean.php | 72 +
.../sabre/vobject/lib/Property/FlatText.php | 46 +
.../sabre/vobject/lib/Property/FloatValue.php | 124 ++
.../lib/Property/ICalendar/CalAddress.php | 63 +
.../vobject/lib/Property/ICalendar/Date.php | 18 +
.../lib/Property/ICalendar/DateTime.php | 366 +++++
.../lib/Property/ICalendar/Duration.php | 79 ++
.../vobject/lib/Property/ICalendar/Period.php | 135 ++
.../vobject/lib/Property/ICalendar/Recur.php | 344 +++++
.../vobject/lib/Property/IntegerValue.php | 76 ++
vendor/sabre/vobject/lib/Property/Text.php | 392 ++++++
vendor/sabre/vobject/lib/Property/Time.php | 131 ++
vendor/sabre/vobject/lib/Property/Unknown.php | 41 +
vendor/sabre/vobject/lib/Property/Uri.php | 116 ++
.../sabre/vobject/lib/Property/UtcOffset.php | 70 +
.../sabre/vobject/lib/Property/VCard/Date.php | 36 +
.../lib/Property/VCard/DateAndOrTime.php | 367 +++++
.../vobject/lib/Property/VCard/DateTime.php | 28 +
.../lib/Property/VCard/LanguageTag.php | 53 +
.../lib/Property/VCard/PhoneNumber.php | 30 +
.../vobject/lib/Property/VCard/TimeStamp.php | 81 ++
vendor/sabre/vobject/lib/Reader.php | 95 ++
.../sabre/vobject/lib/Recur/EventIterator.php | 501 +++++++
.../Recur/MaxInstancesExceededException.php | 17 +
.../lib/Recur/NoInstancesException.php | 18 +
.../sabre/vobject/lib/Recur/RDateIterator.php | 175 +++
.../sabre/vobject/lib/Recur/RRuleIterator.php | 1079 +++++++++++++++
vendor/sabre/vobject/lib/Settings.php | 55 +
.../sabre/vobject/lib/Splitter/ICalendar.php | 106 ++
.../lib/Splitter/SplitterInterface.php | 38 +
vendor/sabre/vobject/lib/Splitter/VCard.php | 74 +
vendor/sabre/vobject/lib/StringUtil.php | 50 +
vendor/sabre/vobject/lib/TimeZoneUtil.php | 272 ++++
.../lib/TimezoneGuesser/FindFromOffset.php | 31 +
.../FindFromTimezoneIdentifier.php | 71 +
.../TimezoneGuesser/FindFromTimezoneMap.php | 78 ++
.../lib/TimezoneGuesser/GuessFromLicEntry.php | 33 +
.../lib/TimezoneGuesser/GuessFromMsTzId.php | 119 ++
.../lib/TimezoneGuesser/TimezoneFinder.php | 10 +
.../lib/TimezoneGuesser/TimezoneGuesser.php | 11 +
vendor/sabre/vobject/lib/UUIDUtil.php | 66 +
vendor/sabre/vobject/lib/VCardConverter.php | 421 ++++++
vendor/sabre/vobject/lib/Version.php | 18 +
vendor/sabre/vobject/lib/Writer.php | 68 +
.../lib/timezonedata/exchangezones.php | 95 ++
.../vobject/lib/timezonedata/lotuszones.php | 101 ++
.../sabre/vobject/lib/timezonedata/php-bc.php | 152 +++
.../lib/timezonedata/php-workaround.php | 46 +
.../vobject/lib/timezonedata/windowszones.php | 152 +++
.../sabre/vobject/resources/schema/xcal.rng | 1192 +++++++++++++++++
.../sabre/vobject/resources/schema/xcard.rng | 388 ++++++
vendor/sabre/xml/LICENSE | 27 +
vendor/sabre/xml/README.md | 26 +
vendor/sabre/xml/composer.json | 70 +
vendor/sabre/xml/lib/ContextStackTrait.php | 116 ++
.../sabre/xml/lib/Deserializer/functions.php | 388 ++++++
vendor/sabre/xml/lib/Element.php | 22 +
vendor/sabre/xml/lib/Element/Base.php | 84 ++
vendor/sabre/xml/lib/Element/Cdata.php | 57 +
vendor/sabre/xml/lib/Element/Elements.php | 102 ++
vendor/sabre/xml/lib/Element/KeyValue.php | 102 ++
vendor/sabre/xml/lib/Element/Uri.php | 95 ++
vendor/sabre/xml/lib/Element/XmlFragment.php | 144 ++
vendor/sabre/xml/lib/LibXMLException.php | 49 +
vendor/sabre/xml/lib/ParseException.php | 16 +
vendor/sabre/xml/lib/Reader.php | 317 +++++
vendor/sabre/xml/lib/Serializer/functions.php | 207 +++
vendor/sabre/xml/lib/Service.php | 326 +++++
vendor/sabre/xml/lib/Version.php | 20 +
vendor/sabre/xml/lib/Writer.php | 261 ++++
vendor/sabre/xml/lib/XmlDeserializable.php | 38 +
vendor/sabre/xml/lib/XmlSerializable.php | 34 +
162 files changed, 25868 insertions(+), 1 deletion(-)
create mode 100644 _test/ChronoIDTest.php
create mode 100644 _test/ChronologicalDateAutoLinkerTest.php
create mode 100644 _test/ChronologicalDayTemplateTest.php
create mode 100644 _test/ChronologicalIcsEventsTest.php
create mode 100644 composer.json
create mode 100644 composer.lock
create mode 100644 js/calendar-widget.js
create mode 100644 src/ChronoID.php
create mode 100644 src/ChronologicalCalendarWidget.php
create mode 100644 src/ChronologicalDateAutoLinker.php
create mode 100644 src/ChronologicalDayTemplate.php
create mode 100644 src/ChronologicalIcsEvents.php
create mode 100644 syntax/calendar.php
create mode 100644 vendor/autoload.php
create mode 100755 vendor/bin/generate_vcards
create mode 100755 vendor/bin/vobject
create mode 100644 vendor/composer/ClassLoader.php
create mode 100644 vendor/composer/InstalledVersions.php
create mode 100644 vendor/composer/LICENSE
create mode 100644 vendor/composer/autoload_classmap.php
create mode 100644 vendor/composer/autoload_files.php
create mode 100644 vendor/composer/autoload_namespaces.php
create mode 100644 vendor/composer/autoload_psr4.php
create mode 100644 vendor/composer/autoload_real.php
create mode 100644 vendor/composer/autoload_static.php
create mode 100644 vendor/composer/installed.json
create mode 100644 vendor/composer/installed.php
create mode 100644 vendor/composer/platform_check.php
create mode 100644 vendor/sabre/uri/LICENSE
create mode 100644 vendor/sabre/uri/composer.json
create mode 100644 vendor/sabre/uri/lib/InvalidUriException.php
create mode 100644 vendor/sabre/uri/lib/Version.php
create mode 100644 vendor/sabre/uri/lib/functions.php
create mode 100644 vendor/sabre/vobject/LICENSE
create mode 100644 vendor/sabre/vobject/README.md
create mode 100755 vendor/sabre/vobject/bin/bench.php
create mode 100644 vendor/sabre/vobject/bin/bench_freebusygenerator.php
create mode 100644 vendor/sabre/vobject/bin/bench_manipulatevcard.php
create mode 100755 vendor/sabre/vobject/bin/fetch_windows_zones.php
create mode 100755 vendor/sabre/vobject/bin/generate_vcards
create mode 100755 vendor/sabre/vobject/bin/generateicalendardata.php
create mode 100755 vendor/sabre/vobject/bin/mergeduplicates.php
create mode 100644 vendor/sabre/vobject/bin/rrulebench.php
create mode 100755 vendor/sabre/vobject/bin/vobject
create mode 100644 vendor/sabre/vobject/composer.json
create mode 100644 vendor/sabre/vobject/lib/BirthdayCalendarGenerator.php
create mode 100644 vendor/sabre/vobject/lib/Cli.php
create mode 100644 vendor/sabre/vobject/lib/Component.php
create mode 100644 vendor/sabre/vobject/lib/Component/Available.php
create mode 100644 vendor/sabre/vobject/lib/Component/VAlarm.php
create mode 100644 vendor/sabre/vobject/lib/Component/VAvailability.php
create mode 100644 vendor/sabre/vobject/lib/Component/VCalendar.php
create mode 100644 vendor/sabre/vobject/lib/Component/VCard.php
create mode 100644 vendor/sabre/vobject/lib/Component/VEvent.php
create mode 100644 vendor/sabre/vobject/lib/Component/VFreeBusy.php
create mode 100644 vendor/sabre/vobject/lib/Component/VJournal.php
create mode 100644 vendor/sabre/vobject/lib/Component/VTimeZone.php
create mode 100644 vendor/sabre/vobject/lib/Component/VTodo.php
create mode 100644 vendor/sabre/vobject/lib/DateTimeParser.php
create mode 100644 vendor/sabre/vobject/lib/Document.php
create mode 100644 vendor/sabre/vobject/lib/ElementList.php
create mode 100644 vendor/sabre/vobject/lib/EofException.php
create mode 100644 vendor/sabre/vobject/lib/FreeBusyData.php
create mode 100644 vendor/sabre/vobject/lib/FreeBusyGenerator.php
create mode 100644 vendor/sabre/vobject/lib/ITip/Broker.php
create mode 100644 vendor/sabre/vobject/lib/ITip/ITipException.php
create mode 100644 vendor/sabre/vobject/lib/ITip/Message.php
create mode 100644 vendor/sabre/vobject/lib/ITip/SameOrganizerForAllComponentsException.php
create mode 100644 vendor/sabre/vobject/lib/InvalidDataException.php
create mode 100644 vendor/sabre/vobject/lib/Node.php
create mode 100644 vendor/sabre/vobject/lib/PHPUnitAssertions.php
create mode 100644 vendor/sabre/vobject/lib/Parameter.php
create mode 100644 vendor/sabre/vobject/lib/ParseException.php
create mode 100644 vendor/sabre/vobject/lib/Parser/Json.php
create mode 100644 vendor/sabre/vobject/lib/Parser/MimeDir.php
create mode 100644 vendor/sabre/vobject/lib/Parser/Parser.php
create mode 100644 vendor/sabre/vobject/lib/Parser/XML.php
create mode 100644 vendor/sabre/vobject/lib/Parser/XML/Element/KeyValue.php
create mode 100644 vendor/sabre/vobject/lib/Property.php
create mode 100644 vendor/sabre/vobject/lib/Property/Binary.php
create mode 100644 vendor/sabre/vobject/lib/Property/Boolean.php
create mode 100644 vendor/sabre/vobject/lib/Property/FlatText.php
create mode 100644 vendor/sabre/vobject/lib/Property/FloatValue.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/CalAddress.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/Date.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/DateTime.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/Duration.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/Period.php
create mode 100644 vendor/sabre/vobject/lib/Property/ICalendar/Recur.php
create mode 100644 vendor/sabre/vobject/lib/Property/IntegerValue.php
create mode 100644 vendor/sabre/vobject/lib/Property/Text.php
create mode 100644 vendor/sabre/vobject/lib/Property/Time.php
create mode 100644 vendor/sabre/vobject/lib/Property/Unknown.php
create mode 100644 vendor/sabre/vobject/lib/Property/Uri.php
create mode 100644 vendor/sabre/vobject/lib/Property/UtcOffset.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/Date.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/DateAndOrTime.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/DateTime.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/LanguageTag.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/PhoneNumber.php
create mode 100644 vendor/sabre/vobject/lib/Property/VCard/TimeStamp.php
create mode 100644 vendor/sabre/vobject/lib/Reader.php
create mode 100644 vendor/sabre/vobject/lib/Recur/EventIterator.php
create mode 100644 vendor/sabre/vobject/lib/Recur/MaxInstancesExceededException.php
create mode 100644 vendor/sabre/vobject/lib/Recur/NoInstancesException.php
create mode 100644 vendor/sabre/vobject/lib/Recur/RDateIterator.php
create mode 100644 vendor/sabre/vobject/lib/Recur/RRuleIterator.php
create mode 100644 vendor/sabre/vobject/lib/Settings.php
create mode 100644 vendor/sabre/vobject/lib/Splitter/ICalendar.php
create mode 100644 vendor/sabre/vobject/lib/Splitter/SplitterInterface.php
create mode 100644 vendor/sabre/vobject/lib/Splitter/VCard.php
create mode 100644 vendor/sabre/vobject/lib/StringUtil.php
create mode 100644 vendor/sabre/vobject/lib/TimeZoneUtil.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/FindFromOffset.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/FindFromTimezoneIdentifier.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/FindFromTimezoneMap.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/GuessFromLicEntry.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/GuessFromMsTzId.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/TimezoneFinder.php
create mode 100644 vendor/sabre/vobject/lib/TimezoneGuesser/TimezoneGuesser.php
create mode 100644 vendor/sabre/vobject/lib/UUIDUtil.php
create mode 100644 vendor/sabre/vobject/lib/VCardConverter.php
create mode 100644 vendor/sabre/vobject/lib/Version.php
create mode 100644 vendor/sabre/vobject/lib/Writer.php
create mode 100644 vendor/sabre/vobject/lib/timezonedata/exchangezones.php
create mode 100644 vendor/sabre/vobject/lib/timezonedata/lotuszones.php
create mode 100644 vendor/sabre/vobject/lib/timezonedata/php-bc.php
create mode 100644 vendor/sabre/vobject/lib/timezonedata/php-workaround.php
create mode 100644 vendor/sabre/vobject/lib/timezonedata/windowszones.php
create mode 100644 vendor/sabre/vobject/resources/schema/xcal.rng
create mode 100644 vendor/sabre/vobject/resources/schema/xcard.rng
create mode 100644 vendor/sabre/xml/LICENSE
create mode 100644 vendor/sabre/xml/README.md
create mode 100644 vendor/sabre/xml/composer.json
create mode 100644 vendor/sabre/xml/lib/ContextStackTrait.php
create mode 100644 vendor/sabre/xml/lib/Deserializer/functions.php
create mode 100644 vendor/sabre/xml/lib/Element.php
create mode 100644 vendor/sabre/xml/lib/Element/Base.php
create mode 100644 vendor/sabre/xml/lib/Element/Cdata.php
create mode 100644 vendor/sabre/xml/lib/Element/Elements.php
create mode 100644 vendor/sabre/xml/lib/Element/KeyValue.php
create mode 100644 vendor/sabre/xml/lib/Element/Uri.php
create mode 100644 vendor/sabre/xml/lib/Element/XmlFragment.php
create mode 100644 vendor/sabre/xml/lib/LibXMLException.php
create mode 100644 vendor/sabre/xml/lib/ParseException.php
create mode 100644 vendor/sabre/xml/lib/Reader.php
create mode 100644 vendor/sabre/xml/lib/Serializer/functions.php
create mode 100644 vendor/sabre/xml/lib/Service.php
create mode 100644 vendor/sabre/xml/lib/Version.php
create mode 100644 vendor/sabre/xml/lib/Writer.php
create mode 100644 vendor/sabre/xml/lib/XmlDeserializable.php
create mode 100644 vendor/sabre/xml/lib/XmlSerializable.php
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 ' |