From 2d5e9541c23fd2dd7b49d79776b8bcbacbfc25ae Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 11 Mar 2026 11:44:32 +0100 Subject: [PATCH] Calendar Sync V1 --- README.md | 118 +++++- action.php | 397 ++++++++++++++++++-- admin/main.php | 72 +++- agents.md | 2 +- conf/default.php | 27 +- js/event-popup.js | 303 +++++++++++++++ lang/de/lang.php | 20 +- lang/en/lang.php | 20 +- src/CalDavClient.php | 466 +++++++++++++++++++++++ src/CalendarEvent.php | 86 +++++ src/CalendarService.php | 553 ++++++++++++++++++++++++++++ src/CalendarSlot.php | 171 +++++++++ src/ChronologicalCalendarWidget.php | 24 +- src/IcsWriter.php | 434 ++++++++++++++++++++++ style.css | 238 ++++++++++++ syntax/calendar.php | 15 +- syntax/maintenance.php | 129 +++++++ 17 files changed, 3011 insertions(+), 64 deletions(-) create mode 100644 js/event-popup.js create mode 100644 src/CalDavClient.php create mode 100644 src/CalendarEvent.php create mode 100644 src/CalendarService.php create mode 100644 src/CalendarSlot.php create mode 100644 src/IcsWriter.php create mode 100644 syntax/maintenance.php diff --git a/README.md b/README.md index 3ba7545..9f3d11a 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,26 @@ Key settings: `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. +- **calendar_ics_files** (REMOVED — replaced by per-slot calendar configuration) + +- **Calendar Slots** (configured via Admin -> luxtools) + The plugin supports 4 calendar slots: `general`, `maintenance`, `slot3`, `slot4`. + Each slot has its own settings: + + - **File**: Local `.ics` file path + - **CalDAV URL**: Remote CalDAV collection URL (optional) + - **Username**: CalDAV authentication username + - **Password**: CalDAV authentication password + - **Color**: CSS color for calendar widget indicators + + A slot is enabled if it has a local file path or a CalDAV URL configured. + The old `calendar_ics_files` setting has been replaced by the `general` slot's file path. + + Default colors: + - general: `#4a90d9` (blue) + - maintenance: `#e67e22` (orange) + - slot3: `#27ae60` (green) + - slot4: `#8e44ad` (purple) - **pagelink_search_depth** Maximum directory depth for `.pagelink` discovery under each configured root. @@ -287,6 +300,10 @@ Notes: - Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node. - Calendar output is marked as non-cacheable to keep missing/existing link styling and current-day highlighting up to date. +- Each day cell shows colored corner indicators for calendar slots that have events on that day. + Indicator positions (clockwise): general = top-left, maintenance = top-right, + slot3 = bottom-right, slot4 = bottom-left. + Indicator colors are taken from the slot's configured color. ### 0.4) Virtual chronological day pages @@ -297,7 +314,8 @@ 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 calendar events from all enabled calendar slots (grouped by slot) +- maintenance tasks with completion buttons (from the maintenance slot) - matching day photos (via existing `{{images>...}}` rendering) when available The page is only created once you edit and save actual content. @@ -314,6 +332,90 @@ luxtools provides an admin-only **Invalidate Cache** action in the page tools me permission errors). - Also useful when actively adding external photos to the current day page. +### 0.6) Multi-calendar slot system + +The plugin supports 4 calendar slots, each with independent configuration for +a local `.ics` file, CalDAV URL, authentication, and display color. + +- **general**: The default event calendar. Events appear on day pages like before. +- **maintenance**: A task-oriented calendar. Events are treated as tasks with + completion tracking. Tasks can be marked complete/reopened via buttons on day pages. +- **slot3**, **slot4**: Reserved for future use. Events from these slots appear + on day pages with the slot's label as the section heading. + +Calendar data is always read from local `.ics` files for rendering. If a remote +CalDAV source is configured, use the sync feature to populate the local file. + +### 0.7) Maintenance task completion + +Maintenance tasks shown on day pages include a "Complete" button. Clicking it: + +1. Updates the event's `STATUS` property in the local `.ics` file. +2. If the maintenance slot has a CalDAV URL configured, also updates the remote + calendar object. +3. Shows visual feedback and reports any remote write failures. + +Completed tasks can be reopened with a "Reopen" button. + +Write-back rules: +- `VEVENT` components: `STATUS:TODO` for open, `STATUS:COMPLETED` for completed. +- `VTODO` components: Uses native completion semantics (`STATUS:COMPLETED`, + `COMPLETED` timestamp, `PERCENT-COMPLETE:100`). +- Recurring events: Completion writes an occurrence override/exception to preserve + per-occurrence state rather than modifying the master event. + +### 0.8) Event popup + +Clicking any event on a day page opens a popup overlay showing: +- Title +- Date/time (formatted in the browser's locale) +- Location (if available) +- Description (if available) +- Calendar slot name + +Close the popup by clicking outside it or pressing Escape. + +### 0.9) Maintenance task list syntax + +Embed a list of open maintenance tasks anywhere on a wiki page: + +``` +{{maintenance_tasks>}} +``` + +This renders all non-completed maintenance tasks due today or earlier, sorted +with overdue tasks first (then by date, time, and title). + +Each task shows its date, optional time, summary, and a "Complete" button. + +### 0.10) CalDAV sync + +If a slot has a CalDAV URL configured, the admin panel provides a sync button. +Triggering sync downloads all calendar objects from the remote CalDAV collection +and merges them into the slot's local `.ics` file. + +Sync is admin-only and does not run automatically. For scheduled sync, set up +a cron job that triggers the sync via the DokuWiki AJAX endpoint. + +### Known limitations + +- **Recurring event completion write-back**: For recurring events, completing a + single occurrence writes an override/exception component to the `.ics` file. + This works well for simple `RRULE` patterns. Some CalDAV servers may handle + the override differently. If the override is rejected by the remote server, + the local file will still have the correct state, but remote sync may + overwrite it on next sync. + +- **Sync direction**: Sync is currently one-directional (remote → local). Local + changes made via task completion are written back to the remote individually, + but a full remote-to-local sync may overwrite local changes if the remote + still has stale data. The completion write-back updates the remote immediately + to mitigate this. + +- **VTODO recurrence**: sabre/vobject's recurrence expansion has limited support + for `VTODO` components. Recurring `VTODO` items may not expand as expected. + Non-recurring `VTODO` items work correctly. + ### 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/action.php b/action.php index d047f77..a0d7c4c 100644 --- a/action.php +++ b/action.php @@ -4,11 +4,15 @@ use dokuwiki\Extension\ActionPlugin; use dokuwiki\Extension\Event; use dokuwiki\Extension\EventHandler; use dokuwiki\plugin\luxtools\CacheInvalidation; +use dokuwiki\plugin\luxtools\CalDavClient; +use dokuwiki\plugin\luxtools\CalendarEvent; +use dokuwiki\plugin\luxtools\CalendarService; +use dokuwiki\plugin\luxtools\CalendarSlot; use dokuwiki\plugin\luxtools\ChronoID; use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget; use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker; use dokuwiki\plugin\luxtools\ChronologicalDayTemplate; -use dokuwiki\plugin\luxtools\ChronologicalIcsEvents; +use dokuwiki\plugin\luxtools\IcsWriter; use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache; require_once(__DIR__ . '/autoload.php'); @@ -65,6 +69,18 @@ class action_plugin_luxtools extends ActionPlugin $this, "handleCalendarWidgetAjax", ); + $controller->register_hook( + "AJAX_CALL_UNKNOWN", + "BEFORE", + $this, + "handleMaintenanceTaskAction", + ); + $controller->register_hook( + "AJAX_CALL_UNKNOWN", + "BEFORE", + $this, + "handleCalendarSyncAction", + ); $controller->register_hook( "ACTION_ACT_PREPROCESS", "BEFORE", @@ -105,6 +121,7 @@ class action_plugin_luxtools extends ActionPlugin "page-link.js", "linkfavicon.js", "calendar-widget.js", + "event-popup.js", "main.js", ]; @@ -147,7 +164,18 @@ class action_plugin_luxtools extends ActionPlugin $this->sendNoStoreHeaders(); - $html = ChronologicalCalendarWidget::render($year, $month, $baseNs); + // Load slot indicators and colors for the calendar widget + $slots = CalendarSlot::loadEnabled($this); + $indicators = CalendarService::monthIndicators($slots, $year, $month); + $slotColors = []; + foreach ($slots as $slot) { + $color = $slot->getColor(); + if ($color !== '') { + $slotColors[$slot->getKey()] = $color; + } + } + + $html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors); if ($html === '') { http_status(500); echo 'Calendar rendering failed'; @@ -458,50 +486,348 @@ class action_plugin_luxtools extends ActionPlugin /** * Render local calendar events section for a given date. * + * Uses the slot-aware CalendarService to render events from all enabled slots. + * * @param string $dateIso * @return string */ protected function renderChronologicalEventsHtml(string $dateIso): string { - $icsConfig = (string)$this->getConf('calendar_ics_files'); - if (trim($icsConfig) === '') return ''; + $slots = CalendarSlot::loadEnabled($this); + if ($slots === []) return ''; - $events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); - if ($events === []) return ''; + $grouped = CalendarService::eventsForDateGrouped($slots, $dateIso); + if ($grouped === []) return ''; - $title = (string)$this->getLang('chronological_events_title'); - if ($title === '') $title = 'Events'; + $html = ''; - $items = ''; - foreach ($events as $entry) { - $summary = trim((string)($entry['summary'] ?? '')); - if ($summary === '') $summary = '(ohne Titel)'; + // Render general events + if (isset($grouped['general'])) { + $title = (string)$this->getLang('chronological_events_title'); + if ($title === '') $title = 'Events'; + $html .= $this->renderEventSection($grouped['general'], $title, 'general'); + } - $time = trim((string)($entry['time'] ?? '')); - $startIso = trim((string)($entry['startIso'] ?? '')); - $isAllDay = (bool)($entry['allDay'] ?? false); + // Render maintenance tasks + if (isset($grouped['maintenance'])) { + $title = (string)$this->getLang('chronological_maintenance_title'); + if ($title === '') $title = 'Tasks'; + $html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso); + } - if ($isAllDay || $time === '') { - $items .= '
  • ' . hsc($summary) . '
  • '; - } else { - $timeHtml = ''; - $items .= '
  • ' . $timeHtml . ' - ' . hsc($summary) . '
  • '; + // Render slot3/slot4 if present + foreach (['slot3', 'slot4'] as $slotKey) { + if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) { + $label = $slots[$slotKey]->getLabel(); + $html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey); } } - if ($items === '') return ''; - $html = ''; + return $html; + } - return '
    ' + /** + * Render a section of events for a given slot. + * + * @param CalendarEvent[] $events + * @param string $title + * @param string $slotKey + * @return string + */ + protected function renderEventSection(array $events, string $title, string $slotKey): string + { + $items = ''; + foreach ($events as $event) { + $items .= $this->renderEventListItem($event); + } + if ($items === '') return ''; + + return '
    ' . '

    ' . hsc($title) . '

    ' - . $html + . '
      ' . $items . '
    ' . '
    '; } + /** + * Render a maintenance task section with completion buttons. + * + * @param CalendarEvent[] $events + * @param string $title + * @param string $dateIso + * @return string + */ + protected function renderMaintenanceSection(array $events, string $title, string $dateIso): string + { + $items = ''; + $ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php'; + + foreach ($events as $event) { + $items .= $this->renderMaintenanceListItem($event, $ajaxUrl); + } + if ($items === '') return ''; + + $secToken = function_exists('getSecurityToken') ? getSecurityToken() : ''; + + return '
    ' + . '

    ' . hsc($title) . '

    ' + . '
      ' . $items . '
    ' + . '
    '; + } + + /** + * Render a single event as a list item with popup data attributes. + * + * @param CalendarEvent $event + * @return string + */ + protected function renderEventListItem(CalendarEvent $event): string + { + $summaryHtml = hsc($event->summary); + + // Build event detail data attributes for popup + $dataAttrs = ' data-luxtools-event="1"'; + $dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"'; + $dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"'; + if ($event->endIso !== '') { + $dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"'; + } + if ($event->location !== '') { + $dataAttrs .= ' data-event-location="' . hsc($event->location) . '"'; + } + if ($event->description !== '') { + $dataAttrs .= ' data-event-description="' . hsc($event->description) . '"'; + } + $dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"'; + $dataAttrs .= ' data-event-slot="' . hsc($event->slotKey) . '"'; + + if ($event->allDay || $event->time === '') { + return '' . $summaryHtml . ''; + } + + $timeHtml = '' + . hsc($event->time) . ''; + return '' . $timeHtml . ' - ' . $summaryHtml . ''; + } + + /** + * Render a maintenance task as a list item with completion button. + * + * @param CalendarEvent $event + * @param string $ajaxUrl + * @return string + */ + protected function renderMaintenanceListItem(CalendarEvent $event, string $ajaxUrl): string + { + $isCompleted = $event->isCompleted(); + $classes = 'luxtools-maintenance-task'; + if ($isCompleted) $classes .= ' luxtools-task-completed'; + + $summaryHtml = hsc($event->summary); + + // Data attributes for popup and completion + $dataAttrs = ' data-luxtools-event="1"'; + $dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"'; + $dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"'; + if ($event->endIso !== '') { + $dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"'; + } + if ($event->location !== '') { + $dataAttrs .= ' data-event-location="' . hsc($event->location) . '"'; + } + if ($event->description !== '') { + $dataAttrs .= ' data-event-description="' . hsc($event->description) . '"'; + } + $dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"'; + $dataAttrs .= ' data-event-slot="maintenance"'; + $dataAttrs .= ' data-task-uid="' . hsc($event->uid) . '"'; + $dataAttrs .= ' data-task-date="' . hsc($event->dateIso) . '"'; + $dataAttrs .= ' data-task-recurrence="' . hsc($event->recurrenceId) . '"'; + $dataAttrs .= ' data-task-status="' . hsc($event->status) . '"'; + + $buttonLabel = $isCompleted + ? (string)$this->getLang('maintenance_task_reopen') + : (string)$this->getLang('maintenance_task_complete'); + if ($buttonLabel === '') $buttonLabel = $isCompleted ? 'Reopen' : 'Complete'; + $buttonAction = $isCompleted ? 'reopen' : 'complete'; + + $buttonHtml = ''; + + $timeHtml = ''; + if (!$event->allDay && $event->time !== '') { + $timeHtml = '' + . hsc($event->time) . ' - '; + } + + return '
  • ' + . $timeHtml + . '' . $summaryHtml . ' ' + . $buttonHtml + . '
  • '; + } + + /** + * Handle AJAX requests for marking maintenance tasks complete/reopen. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function handleMaintenanceTaskAction(Event $event, $param) + { + if ($event->data !== 'luxtools_maintenance_task') return; + + $event->preventDefault(); + $event->stopPropagation(); + + header('Content-Type: application/json; charset=utf-8'); + $this->sendNoStoreHeaders(); + + global $INPUT; + + // Verify security token + if (!checkSecurityToken()) { + http_status(403); + echo json_encode(['ok' => false, 'error' => 'Security token mismatch']); + return; + } + + $action = $INPUT->str('action'); // 'complete' or 'reopen' + $uid = $INPUT->str('uid'); + $dateIso = $INPUT->str('date'); + $recurrence = $INPUT->str('recurrence'); + + if (!in_array($action, ['complete', 'reopen'], true)) { + http_status(400); + echo json_encode(['ok' => false, 'error' => 'Invalid action']); + return; + } + if ($uid === '' || !ChronoID::isIsoDate($dateIso)) { + http_status(400); + echo json_encode(['ok' => false, 'error' => 'Missing uid or date']); + return; + } + + $slots = CalendarSlot::loadAll($this); + $maintenanceSlot = $slots['maintenance'] ?? null; + if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) { + http_status(400); + echo json_encode(['ok' => false, 'error' => 'Maintenance calendar not configured']); + return; + } + + $newStatus = ($action === 'complete') ? 'COMPLETED' : 'TODO'; + + // Update local ICS file + $localOk = false; + $file = $maintenanceSlot->getFile(); + if ($file !== '' && is_file($file)) { + $localOk = IcsWriter::updateEventStatus($file, $uid, $recurrence, $newStatus, $dateIso); + } + + if (!$localOk) { + http_status(500); + echo json_encode(['ok' => false, 'error' => $this->getLang('maintenance_complete_error')]); + return; + } + + // Clear caches so next render picks up changes + CalendarService::clearCache(); + + // Remote CalDAV write-back if configured + $remoteOk = true; + $remoteError = ''; + if ($maintenanceSlot->hasRemoteSource()) { + try { + $caldavOk = CalDavClient::updateEventStatus( + $maintenanceSlot->getCaldavUrl(), + $maintenanceSlot->getUsername(), + $maintenanceSlot->getPassword(), + $uid, + $recurrence, + $newStatus, + $dateIso + ); + if (!$caldavOk) { + $remoteOk = false; + $remoteError = $this->getLang('maintenance_remote_write_failed'); + } + } catch (Throwable $e) { + $remoteOk = false; + $remoteError = $this->getLang('maintenance_remote_write_failed'); + } + } + + $msg = ($action === 'complete') + ? $this->getLang('maintenance_complete_success') + : $this->getLang('maintenance_reopen_success'); + + echo json_encode([ + 'ok' => true, + 'message' => $msg, + 'remoteOk' => $remoteOk, + 'remoteError' => $remoteError, + ]); + } + + /** + * Handle AJAX requests for manual calendar sync. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function handleCalendarSyncAction(Event $event, $param) + { + if ($event->data !== 'luxtools_calendar_sync') return; + + $event->preventDefault(); + $event->stopPropagation(); + + header('Content-Type: application/json; charset=utf-8'); + $this->sendNoStoreHeaders(); + + global $INPUT; + + if (!checkSecurityToken()) { + http_status(403); + echo json_encode(['ok' => false, 'error' => 'Security token mismatch']); + return; + } + + if (!function_exists('auth_isadmin') || !auth_isadmin()) { + http_status(403); + echo json_encode(['ok' => false, 'error' => 'Admin access required']); + return; + } + + $slots = CalendarSlot::loadEnabled($this); + $results = []; + $hasErrors = false; + + foreach ($slots as $slot) { + if (!$slot->hasRemoteSource()) continue; + + $ok = CalDavClient::syncSlot($slot); + $results[$slot->getKey()] = $ok; + if (!$ok) $hasErrors = true; + } + + CalendarService::clearCache(); + + $msg = $hasErrors + ? $this->getLang('calendar_sync_partial') + : $this->getLang('calendar_sync_success'); + + echo json_encode([ + 'ok' => !$hasErrors, + 'message' => $msg, + 'results' => $results, + ]); + } + /** * Build wiki bullet list for local calendar events. * @@ -510,23 +836,20 @@ class action_plugin_luxtools extends ActionPlugin */ protected function buildChronologicalEventsWiki(string $dateIso): string { - $icsConfig = (string)$this->getConf('calendar_ics_files'); - if (trim($icsConfig) === '') return ''; + $slots = CalendarSlot::loadEnabled($this); + if ($slots === []) return ''; - $events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); + $events = CalendarService::eventsForDate($slots, $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); + $summary = str_replace(["\n", "\r"], ' ', $event->summary); - $time = trim((string)($event['time'] ?? '')); - if ((bool)($event['allDay'] ?? false) || $time === '') { + if ($event->allDay || $event->time === '') { $lines[] = ' * ' . $summary; } else { - $lines[] = ' * ' . $time . ' - ' . $summary; + $lines[] = ' * ' . $event->time . ' - ' . $summary; } } diff --git a/admin/main.php b/admin/main.php index e77f66a..61388d6 100644 --- a/admin/main.php +++ b/admin/main.php @@ -8,6 +8,9 @@ if (!defined('DOKU_INC')) die(); class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin { + /** @var string[] Calendar slot keys */ + protected $calendarSlotKeys = ['general', 'maintenance', 'slot3', 'slot4']; + /** @var string[] */ protected $configKeys = [ 'paths', @@ -29,7 +32,26 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin 'gallery_thumb_scale', 'open_service_url', 'image_base_path', - 'calendar_ics_files', + 'calendar_general_file', + 'calendar_general_caldav_url', + 'calendar_general_username', + 'calendar_general_password', + 'calendar_general_color', + 'calendar_maintenance_file', + 'calendar_maintenance_caldav_url', + 'calendar_maintenance_username', + 'calendar_maintenance_password', + 'calendar_maintenance_color', + 'calendar_slot3_file', + 'calendar_slot3_caldav_url', + 'calendar_slot3_username', + 'calendar_slot3_password', + 'calendar_slot3_color', + 'calendar_slot4_file', + 'calendar_slot4_caldav_url', + 'calendar_slot4_username', + 'calendar_slot4_password', + 'calendar_slot4_color', 'pagelink_search_depth', ]; @@ -90,9 +112,14 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin $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; + // Calendar slot settings + foreach ($this->calendarSlotKeys as $slot) { + $newConf['calendar_' . $slot . '_file'] = trim($INPUT->str('calendar_' . $slot . '_file')); + $newConf['calendar_' . $slot . '_caldav_url'] = trim($INPUT->str('calendar_' . $slot . '_caldav_url')); + $newConf['calendar_' . $slot . '_username'] = trim($INPUT->str('calendar_' . $slot . '_username')); + $newConf['calendar_' . $slot . '_password'] = trim($INPUT->str('calendar_' . $slot . '_password')); + $newConf['calendar_' . $slot . '_color'] = trim($INPUT->str('calendar_' . $slot . '_color')); + } $depth = (int)$INPUT->int('pagelink_search_depth'); if ($depth < 0) $depth = 0; @@ -240,11 +267,38 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin echo ''; echo '
    '; - // calendar_ics_files - $icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files'); - echo '
    '; + // Calendar slot settings + $slotLabels = [ + 'general' => 'General', + 'maintenance' => 'Maintenance', + 'slot3' => 'Slot 3', + 'slot4' => 'Slot 4', + ]; + foreach ($this->calendarSlotKeys as $slot) { + echo '

    ' . hsc($this->getLang('calendar_slot_heading') . ': ' . $slotLabels[$slot]) . '

    '; + + $prefix = 'calendar_' . $slot . '_'; + + echo '
    '; + + echo '
    '; + + echo '
    '; + + echo '
    '; + + echo '
    '; + } // pagelink_search_depth echo '