Calendar Sync V1

This commit is contained in:
2026-03-11 11:44:32 +01:00
parent 87f6839b0d
commit 2d5e9541c2
17 changed files with 3011 additions and 64 deletions

118
README.md
View File

@@ -163,13 +163,26 @@ Key settings:
`YYYY-MM-DD` are listed automatically. `YYYY-MM-DD` are listed automatically.
If a yearly subfolder exists (for example `.../2026/`), it is preferred. If a yearly subfolder exists (for example `.../2026/`), it is preferred.
- **calendar_ics_files** - **calendar_ics_files** (REMOVED — replaced by per-slot calendar configuration)
Local calendar `.ics` files (one absolute file path per line).
Events are parsed by `sabre/vobject` and shown on matching chronological day pages. - **Calendar Slots** (configured via Admin -> luxtools)
Recurrence and exclusions from the ICS are respected. For timed entries, the The plugin supports 4 calendar slots: `general`, `maintenance`, `slot3`, `slot4`.
page stores the original timestamp and renders the visible time in the Each slot has its own settings:
browser's local timezone.
Multi-day events appear on each overlapping day. - **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** - **pagelink_search_depth**
Maximum directory depth for `.pagelink` discovery under each configured root. 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. - 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 - Calendar output is marked as non-cacheable to keep missing/existing link styling and
current-day highlighting up to date. 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 ### 0.4) Virtual chronological day pages
@@ -297,7 +314,8 @@ default "page does not exist" output.
The virtual page includes: The virtual page includes:
- a German-formatted heading (for example `Freitag, 13. Februar 2026`) - 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 - matching day photos (via existing `{{images>...}}` rendering) when available
The page is only created once you edit and save actual content. 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). permission errors).
- Also useful when actively adding external photos to the current day page. - 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 ### 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: 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:

View File

@@ -4,11 +4,15 @@ use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\Event; use dokuwiki\Extension\Event;
use dokuwiki\Extension\EventHandler; use dokuwiki\Extension\EventHandler;
use dokuwiki\plugin\luxtools\CacheInvalidation; 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\ChronoID;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget; use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker; use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate; use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents; use dokuwiki\plugin\luxtools\IcsWriter;
use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache; use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache;
require_once(__DIR__ . '/autoload.php'); require_once(__DIR__ . '/autoload.php');
@@ -65,6 +69,18 @@ class action_plugin_luxtools extends ActionPlugin
$this, $this,
"handleCalendarWidgetAjax", "handleCalendarWidgetAjax",
); );
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleMaintenanceTaskAction",
);
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleCalendarSyncAction",
);
$controller->register_hook( $controller->register_hook(
"ACTION_ACT_PREPROCESS", "ACTION_ACT_PREPROCESS",
"BEFORE", "BEFORE",
@@ -105,6 +121,7 @@ class action_plugin_luxtools extends ActionPlugin
"page-link.js", "page-link.js",
"linkfavicon.js", "linkfavicon.js",
"calendar-widget.js", "calendar-widget.js",
"event-popup.js",
"main.js", "main.js",
]; ];
@@ -147,7 +164,18 @@ class action_plugin_luxtools extends ActionPlugin
$this->sendNoStoreHeaders(); $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 === '') { if ($html === '') {
http_status(500); http_status(500);
echo 'Calendar rendering failed'; echo 'Calendar rendering failed';
@@ -458,50 +486,348 @@ class action_plugin_luxtools extends ActionPlugin
/** /**
* Render local calendar events section for a given date. * Render local calendar events section for a given date.
* *
* Uses the slot-aware CalendarService to render events from all enabled slots.
*
* @param string $dateIso * @param string $dateIso
* @return string * @return string
*/ */
protected function renderChronologicalEventsHtml(string $dateIso): string protected function renderChronologicalEventsHtml(string $dateIso): string
{ {
$icsConfig = (string)$this->getConf('calendar_ics_files'); $slots = CalendarSlot::loadEnabled($this);
if (trim($icsConfig) === '') return ''; if ($slots === []) return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); $grouped = CalendarService::eventsForDateGrouped($slots, $dateIso);
if ($events === []) return ''; if ($grouped === []) return '';
$title = (string)$this->getLang('chronological_events_title'); $html = '';
if ($title === '') $title = 'Events';
$items = ''; // Render general events
foreach ($events as $entry) { if (isset($grouped['general'])) {
$summary = trim((string)($entry['summary'] ?? '')); $title = (string)$this->getLang('chronological_events_title');
if ($summary === '') $summary = '(ohne Titel)'; if ($title === '') $title = 'Events';
$html .= $this->renderEventSection($grouped['general'], $title, 'general');
}
$time = trim((string)($entry['time'] ?? '')); // Render maintenance tasks
$startIso = trim((string)($entry['startIso'] ?? '')); if (isset($grouped['maintenance'])) {
$isAllDay = (bool)($entry['allDay'] ?? false); $title = (string)$this->getLang('chronological_maintenance_title');
if ($title === '') $title = 'Tasks';
$html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso);
}
if ($isAllDay || $time === '') { // Render slot3/slot4 if present
$items .= '<li>' . hsc($summary) . '</li>'; foreach (['slot3', 'slot4'] as $slotKey) {
} else { if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
$timeHtml = '<span class="luxtools-event-time"'; $label = $slots[$slotKey]->getLabel();
if ($startIso !== '') { $html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey);
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
}
$timeHtml .= '>' . hsc($time) . '</span>';
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
} }
} }
if ($items === '') return ''; return $html;
$html = '<ul>' . $items . '</ul>'; }
return '<div class="luxtools-plugin luxtools-chronological-events">' /**
* 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 '<div class="luxtools-plugin luxtools-chronological-events luxtools-slot-' . hsc($slotKey) . '">'
. '<h2>' . hsc($title) . '</h2>' . '<h2>' . hsc($title) . '</h2>'
. $html . '<ul>' . $items . '</ul>'
. '</div>'; . '</div>';
} }
/**
* 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 '<div class="luxtools-plugin luxtools-chronological-events luxtools-chronological-maintenance"'
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
. ' data-luxtools-sectok="' . hsc($secToken) . '">'
. '<h2>' . hsc($title) . '</h2>'
. '<ul>' . $items . '</ul>'
. '</div>';
}
/**
* 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 '<li' . $dataAttrs . '><span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
}
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
. hsc($event->time) . '</span>';
return '<li' . $dataAttrs . '>' . $timeHtml . ' - <span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
}
/**
* 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 = '<button type="button" class="luxtools-task-action" data-action="' . hsc($buttonAction) . '">'
. hsc($buttonLabel) . '</button>';
$timeHtml = '';
if (!$event->allDay && $event->time !== '') {
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
. hsc($event->time) . '</span> - ';
}
return '<li class="' . hsc($classes) . '"' . $dataAttrs . '>'
. $timeHtml
. '<span class="luxtools-event-summary">' . $summaryHtml . '</span> '
. $buttonHtml
. '</li>';
}
/**
* 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. * Build wiki bullet list for local calendar events.
* *
@@ -510,23 +836,20 @@ class action_plugin_luxtools extends ActionPlugin
*/ */
protected function buildChronologicalEventsWiki(string $dateIso): string protected function buildChronologicalEventsWiki(string $dateIso): string
{ {
$icsConfig = (string)$this->getConf('calendar_ics_files'); $slots = CalendarSlot::loadEnabled($this);
if (trim($icsConfig) === '') return ''; if ($slots === []) return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso); $events = CalendarService::eventsForDate($slots, $dateIso);
if ($events === []) return ''; if ($events === []) return '';
$lines = []; $lines = [];
foreach ($events as $event) { foreach ($events as $event) {
$summary = trim((string)($event['summary'] ?? '')); $summary = str_replace(["\n", "\r"], ' ', $event->summary);
if ($summary === '') $summary = '(ohne Titel)';
$summary = str_replace(["\n", "\r"], ' ', $summary);
$time = trim((string)($event['time'] ?? '')); if ($event->allDay || $event->time === '') {
if ((bool)($event['allDay'] ?? false) || $time === '') {
$lines[] = ' * ' . $summary; $lines[] = ' * ' . $summary;
} else { } else {
$lines[] = ' * ' . $time . ' - ' . $summary; $lines[] = ' * ' . $event->time . ' - ' . $summary;
} }
} }

View File

@@ -8,6 +8,9 @@ if (!defined('DOKU_INC')) die();
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
{ {
/** @var string[] Calendar slot keys */
protected $calendarSlotKeys = ['general', 'maintenance', 'slot3', 'slot4'];
/** @var string[] */ /** @var string[] */
protected $configKeys = [ protected $configKeys = [
'paths', 'paths',
@@ -29,7 +32,26 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'gallery_thumb_scale', 'gallery_thumb_scale',
'open_service_url', 'open_service_url',
'image_base_path', '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', '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['open_service_url'] = $INPUT->str('open_service_url');
$newConf['image_base_path'] = $INPUT->str('image_base_path'); $newConf['image_base_path'] = $INPUT->str('image_base_path');
$icsFiles = $INPUT->str('calendar_ics_files'); // Calendar slot settings
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles); foreach ($this->calendarSlotKeys as $slot) {
$newConf['calendar_ics_files'] = $icsFiles; $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'); $depth = (int)$INPUT->int('pagelink_search_depth');
if ($depth < 0) $depth = 0; if ($depth < 0) $depth = 0;
@@ -240,11 +267,38 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />'; echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
echo '</label><br />'; echo '</label><br />';
// calendar_ics_files // Calendar slot settings
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files'); $slotLabels = [
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />'; 'general' => 'General',
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>'; 'maintenance' => 'Maintenance',
echo '</label><br />'; 'slot3' => 'Slot 3',
'slot4' => 'Slot 4',
];
foreach ($this->calendarSlotKeys as $slot) {
echo '<h2>' . hsc($this->getLang('calendar_slot_heading') . ': ' . $slotLabels[$slot]) . '</h2>';
$prefix = 'calendar_' . $slot . '_';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_file')) . '</span> ';
echo '<input type="text" class="edit" name="' . hsc($prefix . 'file') . '" value="' . hsc((string)$this->getConf($prefix . 'file')) . '" />';
echo '</label><br />';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_caldav_url')) . '</span> ';
echo '<input type="text" class="edit" name="' . hsc($prefix . 'caldav_url') . '" value="' . hsc((string)$this->getConf($prefix . 'caldav_url')) . '" />';
echo '</label><br />';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_username')) . '</span> ';
echo '<input type="text" class="edit" name="' . hsc($prefix . 'username') . '" value="' . hsc((string)$this->getConf($prefix . 'username')) . '" />';
echo '</label><br />';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_password')) . '</span> ';
echo '<input type="password" class="edit" name="' . hsc($prefix . 'password') . '" value="' . hsc((string)$this->getConf($prefix . 'password')) . '" />';
echo '</label><br />';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_color')) . '</span> ';
echo '<input type="color" name="' . hsc($prefix . 'color') . '" value="' . hsc((string)$this->getConf($prefix . 'color') ?: '#999999') . '" />';
echo '</label><br />';
}
// pagelink_search_depth // pagelink_search_depth
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> '; echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';

View File

@@ -37,8 +37,31 @@ $conf['open_service_url'] = 'http://127.0.0.1:8765';
// Base filesystem path for chronological photo integration. // Base filesystem path for chronological photo integration.
$conf['image_base_path'] = ''; $conf['image_base_path'] = '';
// Local calendar ICS files (one absolute file path per line). // Calendar slot configuration (4 slots: general, maintenance, slot3, slot4)
$conf['calendar_ics_files'] = ''; // Each slot has: file, caldav_url, username, password, color
$conf['calendar_general_file'] = '';
$conf['calendar_general_caldav_url'] = '';
$conf['calendar_general_username'] = '';
$conf['calendar_general_password'] = '';
$conf['calendar_general_color'] = '#4a90d9';
$conf['calendar_maintenance_file'] = '';
$conf['calendar_maintenance_caldav_url'] = '';
$conf['calendar_maintenance_username'] = '';
$conf['calendar_maintenance_password'] = '';
$conf['calendar_maintenance_color'] = '#e67e22';
$conf['calendar_slot3_file'] = '';
$conf['calendar_slot3_caldav_url'] = '';
$conf['calendar_slot3_username'] = '';
$conf['calendar_slot3_password'] = '';
$conf['calendar_slot3_color'] = '#27ae60';
$conf['calendar_slot4_file'] = '';
$conf['calendar_slot4_caldav_url'] = '';
$conf['calendar_slot4_username'] = '';
$conf['calendar_slot4_password'] = '';
$conf['calendar_slot4_color'] = '#8e44ad';
// Maximum depth when searching for .pagelink files under allowed roots. // Maximum depth when searching for .pagelink files under allowed roots.
$conf['pagelink_search_depth'] = 3; $conf['pagelink_search_depth'] = 3;

303
js/event-popup.js Normal file
View File

@@ -0,0 +1,303 @@
/* global window, document, jQuery */
/**
* Event Popup and Maintenance Task Handling
*
* - Clicking an event item with data-luxtools-event="1" opens a detail popup.
* - Clicking a maintenance task action button sends an AJAX request to
* complete/reopen the task.
*/
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// ============================================================
// Event Popup
// ============================================================
var EventPopup = (function () {
var overlay = null;
var popup = null;
function ensureElements() {
if (overlay) return;
overlay = document.createElement('div');
overlay.className = 'luxtools-event-popup-overlay';
overlay.style.display = 'none';
popup = document.createElement('div');
popup.className = 'luxtools-event-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-modal', 'true');
overlay.appendChild(popup);
document.body.appendChild(overlay);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
close();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.style.display !== 'none') {
close();
}
});
}
function open(el) {
ensureElements();
var summary = el.getAttribute('data-event-summary') || '';
var start = el.getAttribute('data-event-start') || '';
var end = el.getAttribute('data-event-end') || '';
var location = el.getAttribute('data-event-location') || '';
var description = el.getAttribute('data-event-description') || '';
var allDay = el.getAttribute('data-event-allday') === '1';
var slot = el.getAttribute('data-event-slot') || '';
var html = '<div class="luxtools-event-popup-content">';
html += '<button type="button" class="luxtools-event-popup-close" aria-label="Close">&times;</button>';
html += '<h3 class="luxtools-event-popup-title">' + escapeHtml(summary) + '</h3>';
// Date/time
html += '<div class="luxtools-event-popup-field">';
if (allDay) {
html += '<strong>Date:</strong> ' + formatDate(start);
if (end) {
html += ' &ndash; ' + formatDate(end);
}
} else {
html += '<strong>Time:</strong> ' + formatDateTime(start);
if (end) {
html += ' &ndash; ' + formatDateTime(end);
}
}
html += '</div>';
if (location) {
html += '<div class="luxtools-event-popup-field"><strong>Location:</strong> ' + escapeHtml(location) + '</div>';
}
if (description) {
html += '<div class="luxtools-event-popup-field luxtools-event-popup-description">'
+ '<strong>Description:</strong><br>'
+ escapeHtml(description).replace(/\n/g, '<br>')
+ '</div>';
}
if (slot) {
html += '<div class="luxtools-event-popup-slot"><em>' + escapeHtml(slot) + '</em></div>';
}
html += '</div>';
popup.innerHTML = html;
overlay.style.display = 'flex';
// Close button inside popup
var closeBtn = popup.querySelector('.luxtools-event-popup-close');
if (closeBtn) {
closeBtn.addEventListener('click', close);
}
}
function close() {
if (overlay) {
overlay.style.display = 'none';
}
}
function formatDate(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return d.toLocaleDateString();
}
function formatDateTime(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
return {
open: open,
close: close,
};
})();
// ============================================================
// Maintenance Task Actions
// ============================================================
var MaintenanceTasks = (function () {
function handleAction(button) {
var action = button.getAttribute('data-action');
if (!action) return;
// Find the containing list item or container with task data
var item = button.closest('[data-task-uid]');
if (!item) {
// Try the syntax plugin format
item = button.closest('[data-uid]');
}
if (!item) return;
var uid = item.getAttribute('data-task-uid') || item.getAttribute('data-uid') || '';
var date = item.getAttribute('data-task-date') || item.getAttribute('data-date') || '';
var recurrence = item.getAttribute('data-task-recurrence') || item.getAttribute('data-recurrence') || '';
if (!uid || !date) return;
// Find AJAX URL and security token from parent container or global
var container = item.closest('[data-luxtools-ajax-url]');
var ajaxUrl = container ? container.getAttribute('data-luxtools-ajax-url') : '';
var sectok = container ? container.getAttribute('data-luxtools-sectok') : '';
if (!ajaxUrl) {
// Fallback: use DokuWiki's standard AJAX endpoint
ajaxUrl = (window.DOKU_BASE || '/') + 'lib/exe/ajax.php';
}
if (!sectok && window.JSINFO && window.JSINFO.sectok) {
sectok = window.JSINFO.sectok;
}
button.disabled = true;
button.textContent = '...';
var params = 'call=luxtools_maintenance_task'
+ '&action=' + encodeURIComponent(action)
+ '&uid=' + encodeURIComponent(uid)
+ '&date=' + encodeURIComponent(date)
+ '&recurrence=' + encodeURIComponent(recurrence)
+ '&sectok=' + encodeURIComponent(sectok);
var xhr = new XMLHttpRequest();
xhr.open('POST', ajaxUrl, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = function () {
var result;
try {
result = JSON.parse(xhr.responseText);
} catch (e) {
result = { ok: false, error: 'Invalid response' };
}
if (result.ok) {
// Visual feedback: mark item as done or revert
if (action === 'complete') {
item.classList.add('luxtools-task-completed');
// Fade out and remove after a short delay
item.style.opacity = '0.5';
setTimeout(function () {
item.style.display = 'none';
}, 1000);
} else {
item.classList.remove('luxtools-task-completed');
item.style.opacity = '1';
button.textContent = 'Complete';
button.setAttribute('data-action', 'complete');
button.disabled = false;
}
// Show remote write warning if applicable
if (result.remoteOk === false && result.remoteError) {
showNotification(result.remoteError, 'warning');
}
} else {
var errMsg = result.error || 'Action failed';
showNotification(errMsg, 'error');
button.textContent = action === 'complete' ? 'Complete' : 'Reopen';
button.disabled = false;
}
};
xhr.onerror = function () {
showNotification('Network error', 'error');
button.textContent = action === 'complete' ? 'Complete' : 'Reopen';
button.disabled = false;
};
xhr.send(params);
}
function showNotification(message, type) {
// Use DokuWiki msg() if available
if (typeof window.msg === 'function') {
var level = (type === 'error') ? -1 : ((type === 'warning') ? 0 : 1);
window.msg(message, level);
return;
}
// Fallback: simple notification
var notif = document.createElement('div');
notif.className = 'luxtools-notification luxtools-notification-' + type;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(function () {
if (notif.parentNode) notif.parentNode.removeChild(notif);
}, 5000);
}
return {
handleAction: handleAction,
};
})();
// ============================================================
// Event Delegation
// ============================================================
document.addEventListener('click', function (e) {
var target = e.target;
// Maintenance task action buttons (day pages)
if (target.classList && target.classList.contains('luxtools-task-action')) {
e.preventDefault();
MaintenanceTasks.handleAction(target);
return;
}
// Maintenance task complete buttons (syntax plugin list)
if (target.classList && target.classList.contains('luxtools-task-complete-btn')) {
e.preventDefault();
MaintenanceTasks.handleAction(target);
return;
}
// Event popup: find closest element with data-luxtools-event
var eventEl = target.closest ? target.closest('[data-luxtools-event]') : null;
if (!eventEl) {
// Traverse manually for older browsers
var el = target;
while (el && el !== document) {
if (el.getAttribute && el.getAttribute('data-luxtools-event') === '1') {
eventEl = el;
break;
}
el = el.parentNode;
}
}
if (eventEl) {
// Don't open popup if clicking a button inside the event item
if (target.tagName === 'BUTTON' || target.closest('button')) return;
e.preventDefault();
EventPopup.open(eventEl);
}
}, false);
Luxtools.EventPopup = EventPopup;
Luxtools.MaintenanceTasks = MaintenanceTasks;
})();

View File

@@ -64,8 +64,12 @@ $lang["open_service_url"] =
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765)."; "URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
$lang["image_base_path"] = $lang["image_base_path"] =
"Basis-Dateisystempfad für die chronologische Foto-Integration."; "Basis-Dateisystempfad für die chronologische Foto-Integration.";
$lang["calendar_ics_files"] = $lang["calendar_slot_heading"] = "Kalender-Slot";
"Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile)."; $lang["calendar_slot_file"] = "Lokaler ICS-Dateipfad";
$lang["calendar_slot_caldav_url"] = "CalDAV-URL";
$lang["calendar_slot_username"] = "Benutzername";
$lang["calendar_slot_password"] = "Passwort";
$lang["calendar_slot_color"] = "Anzeigefarbe";
$lang["pagelink_search_depth"] = $lang["pagelink_search_depth"] =
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root)."; "Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
@@ -85,6 +89,18 @@ $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft"; $lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
$lang["chronological_photos_title"] = "Fotos"; $lang["chronological_photos_title"] = "Fotos";
$lang["chronological_events_title"] = "Termine"; $lang["chronological_events_title"] = "Termine";
$lang["chronological_maintenance_title"] = "Aufgaben";
$lang["maintenance_task_complete"] = "Erledigen";
$lang["maintenance_task_reopen"] = "Wieder öffnen";
$lang["maintenance_no_tasks"] = "Keine offenen Aufgaben.";
$lang["maintenance_complete_success"] = "Aufgabe als erledigt markiert.";
$lang["maintenance_complete_error"] = "Aktualisierung der Aufgabe fehlgeschlagen.";
$lang["maintenance_reopen_success"] = "Aufgabe wieder geöffnet.";
$lang["maintenance_remote_write_failed"] = "Lokale Aktualisierung erfolgreich, aber CalDAV-Update fehlgeschlagen. Wird bei nächster Synchronisierung erneut versucht.";
$lang["calendar_sync_button"] = "Kalender synchronisieren";
$lang["calendar_sync_success"] = "Kalender-Synchronisierung abgeschlossen.";
$lang["calendar_sync_error"] = "Kalender-Synchronisierung fehlgeschlagen.";
$lang["calendar_sync_partial"] = "Kalender-Synchronisierung mit Fehlern abgeschlossen.";
$lang["cache_invalidate_button"] = "Cache invalidieren"; $lang["cache_invalidate_button"] = "Cache invalidieren";
$lang["cache_invalidate_button_title"] = "Gesamten DokuWiki-Cache leeren"; $lang["cache_invalidate_button_title"] = "Gesamten DokuWiki-Cache leeren";
$lang["cache_invalidate_success"] = "DokuWiki-Cache invalidiert."; $lang["cache_invalidate_success"] = "DokuWiki-Cache invalidiert.";

View File

@@ -64,8 +64,12 @@ $lang["open_service_url"] =
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765)."; "Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
$lang["image_base_path"] = $lang["image_base_path"] =
"Base filesystem path for chronological photo integration."; "Base filesystem path for chronological photo integration.";
$lang["calendar_ics_files"] = $lang["calendar_slot_heading"] = "Calendar Slot";
"Local calendar .ics files (one absolute file path per line)."; $lang["calendar_slot_file"] = "Local ICS file path";
$lang["calendar_slot_caldav_url"] = "CalDAV URL";
$lang["calendar_slot_username"] = "Username";
$lang["calendar_slot_password"] = "Password";
$lang["calendar_slot_color"] = "Display color";
$lang["pagelink_search_depth"] = $lang["pagelink_search_depth"] =
"Maximum directory depth for .pagelink search (0 = only root)."; "Maximum directory depth for .pagelink search (0 = only root).";
@@ -86,6 +90,18 @@ $lang["pagelink_multi_warning"] = "Multiple folders linked";
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM."; $lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
$lang["chronological_photos_title"] = "Photos"; $lang["chronological_photos_title"] = "Photos";
$lang["chronological_events_title"] = "Events"; $lang["chronological_events_title"] = "Events";
$lang["chronological_maintenance_title"] = "Tasks";
$lang["maintenance_task_complete"] = "Complete";
$lang["maintenance_task_reopen"] = "Reopen";
$lang["maintenance_no_tasks"] = "No open tasks.";
$lang["maintenance_complete_success"] = "Task marked as completed.";
$lang["maintenance_complete_error"] = "Failed to update task.";
$lang["maintenance_reopen_success"] = "Task reopened.";
$lang["maintenance_remote_write_failed"] = "Local update succeeded, but remote CalDAV update failed. Will retry on next sync.";
$lang["calendar_sync_button"] = "Sync Calendars";
$lang["calendar_sync_success"] = "Calendar sync completed.";
$lang["calendar_sync_error"] = "Calendar sync failed.";
$lang["calendar_sync_partial"] = "Calendar sync completed with errors.";
$lang["cache_invalidate_button"] = "Invalidate Cache"; $lang["cache_invalidate_button"] = "Invalidate Cache";
$lang["cache_invalidate_button_title"] = "Purge the entire DokuWiki cache"; $lang["cache_invalidate_button_title"] = "Purge the entire DokuWiki cache";
$lang["cache_invalidate_success"] = "DokuWiki cache invalidated."; $lang["cache_invalidate_success"] = "DokuWiki cache invalidated.";

466
src/CalDavClient.php Normal file
View File

@@ -0,0 +1,466 @@
<?php
namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Throwable;
/**
* CalDAV client for remote calendar operations.
*
* Supports:
* - Downloading a full calendar collection into a local ICS file (sync)
* - Updating the STATUS of a single event/task occurrence on the remote server
*
* Uses plain PHP curl for HTTP. No additional dependencies required.
*/
class CalDavClient
{
/** @var int HTTP timeout in seconds */
protected const TIMEOUT = 30;
/**
* Update the STATUS of a specific event or task on the remote CalDAV server.
*
* Fetches the calendar object containing the UID, modifies its status,
* and PUTs it back using the ETag for conflict detection.
*
* @param string $caldavUrl CalDAV collection URL
* @param string $username HTTP Basic auth username
* @param string $password HTTP Basic auth password
* @param string $uid Event/task UID
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
* @param string $dateIso Occurrence date YYYY-MM-DD
* @return bool True if the remote update succeeded
*/
public static function updateEventStatus(
string $caldavUrl,
string $username,
string $password,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
if ($caldavUrl === '' || $uid === '') return false;
try {
// Find the calendar object href for this UID via REPORT
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
if ($objectInfo === null) return false;
$objectHref = $objectInfo['href'];
$etag = $objectInfo['etag'];
$calendarData = $objectInfo['data'];
// Parse and update the status
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
$updated = IcsWriter::applyStatusUpdateToCalendar(
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
);
if (!$updated) return false;
$newData = $calendar->serialize();
// PUT the updated object back with If-Match for conflict detection
return self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
} catch (Throwable $e) {
return false;
}
}
/**
* Sync a remote CalDAV calendar collection into the slot's local ICS file.
*
* Downloads all calendar objects from the collection and merges them
* into a single ICS file at the slot's configured file path.
*
* @param CalendarSlot $slot
* @return bool True if sync succeeded
*/
public static function syncSlot(CalendarSlot $slot): bool
{
if (!$slot->hasRemoteSource()) return false;
$caldavUrl = $slot->getCaldavUrl();
$username = $slot->getUsername();
$password = $slot->getPassword();
$localFile = $slot->getFile();
if ($localFile === '') {
// No local file configured - nothing to sync into
return false;
}
try {
$objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password);
if ($objects === null) return false;
$merged = self::mergeCalendarObjects($objects);
if ($merged === '') return false;
return IcsWriter::atomicWritePublic($localFile, $merged);
} catch (Throwable $e) {
return false;
}
}
/**
* Find a specific calendar object by UID using a REPORT request.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $uid
* @return array{href: string, etag: string, data: string}|null
*/
protected static function findObjectByUid(
string $caldavUrl,
string $username,
string $password,
string $uid
): ?array {
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
'<D:prop>' .
'<D:getetag/>' .
'<C:calendar-data/>' .
'</D:prop>' .
'<C:filter>' .
'<C:comp-filter name="VCALENDAR">' .
'<C:comp-filter name="VEVENT">' .
'<C:prop-filter name="UID">' .
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
'</C:prop-filter>' .
'</C:comp-filter>' .
'</C:comp-filter>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
if ($response === null) {
// Try VTODO filter as well
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
'<D:prop>' .
'<D:getetag/>' .
'<C:calendar-data/>' .
'</D:prop>' .
'<C:filter>' .
'<C:comp-filter name="VCALENDAR">' .
'<C:comp-filter name="VTODO">' .
'<C:prop-filter name="UID">' .
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
'</C:prop-filter>' .
'</C:comp-filter>' .
'</C:comp-filter>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
}
if ($response === null) return null;
return self::parseReportResponse($response, $caldavUrl);
}
/**
* Fetch all calendar objects from a CalDAV collection.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @return string[]|null Array of ICS data strings, or null on failure
*/
protected static function fetchAllCalendarObjects(
string $caldavUrl,
string $username,
string $password
): ?array {
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
'<D:prop>' .
'<C:calendar-data/>' .
'</D:prop>' .
'<C:filter>' .
'<C:comp-filter name="VCALENDAR"/>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
if ($response === null) return null;
return self::parseCalendarDataFromMultistatus($response);
}
/**
* Merge multiple ICS calendar objects into a single calendar string.
*
* @param string[] $objects Array of ICS data strings
* @return string Merged ICS content
*/
protected static function mergeCalendarObjects(array $objects): string
{
if ($objects === []) return '';
$merged = new VCalendar();
$merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN';
$merged->VERSION = '2.0';
foreach ($objects as $icsData) {
if (trim($icsData) === '') continue;
try {
$cal = Reader::read($icsData, Reader::OPTION_FORGIVING);
if (!($cal instanceof VCalendar)) continue;
// Copy VTIMEZONE components first
foreach ($cal->select('VTIMEZONE') as $tz) {
// Check if this timezone already exists in merged
$tzid = (string)($tz->TZID ?? '');
$exists = false;
foreach ($merged->select('VTIMEZONE') as $existingTz) {
if ((string)($existingTz->TZID ?? '') === $tzid) {
$exists = true;
break;
}
}
if (!$exists) {
$merged->add(clone $tz);
}
}
// Copy VEVENT and VTODO components
foreach ($cal->select('VEVENT') as $component) {
$merged->add(clone $component);
}
foreach ($cal->select('VTODO') as $component) {
$merged->add(clone $component);
}
} catch (Throwable $e) {
// Skip malformed objects
continue;
}
}
return $merged->serialize();
}
/**
* PUT a calendar object back to the server.
*
* @param string $href Full URL of the calendar object
* @param string $username
* @param string $password
* @param string $data ICS data to write
* @param string $etag ETag for If-Match header (empty to skip)
* @return bool
*/
protected static function putCalendarObject(
string $href,
string $username,
string $password,
string $data,
string $etag
): bool {
$headers = [
'Content-Type: text/calendar; charset=utf-8',
];
if ($etag !== '') {
$headers[] = 'If-Match: ' . $etag;
}
$response = self::request('PUT', $href, $username, $password, $data, $headers);
// PUT returns null body but we check by HTTP status via the request method
// A successful PUT returns 2xx
return $response !== null;
}
/**
* Parse a REPORT multistatus response to extract href, etag, and calendar data
* for the first matching object.
*
* @param string $xml
* @param string $baseUrl
* @return array{href: string, etag: string, data: string}|null
*/
protected static function parseReportResponse(string $xml, string $baseUrl): ?array
{
$doc = self::parseXml($xml);
if ($doc === null) return null;
$doc->registerXPathNamespace('d', 'DAV:');
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$responses = $doc->xpath('//d:response');
if (!$responses || count($responses) === 0) return null;
foreach ($responses as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$hrefs = $resp->xpath('d:href');
$href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : '';
$etags = $resp->xpath('.//d:getetag');
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
// Strip surrounding quotes from etag if present
$etag = trim($etag, '"');
$caldata = $resp->xpath('.//cal:calendar-data');
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
if ($href === '' || $data === '') continue;
// Resolve relative href to absolute URL
if (strpos($href, 'http') !== 0) {
$parsed = parse_url($baseUrl);
$scheme = ($parsed['scheme'] ?? 'https');
$host = ($parsed['host'] ?? '');
$port = isset($parsed['port']) ? (':' . $parsed['port']) : '';
$href = $scheme . '://' . $host . $port . $href;
}
return [
'href' => $href,
'etag' => $etag,
'data' => $data,
];
}
return null;
}
/**
* Parse calendar-data elements from a CalDAV multistatus response.
*
* @param string $xml
* @return string[]
*/
protected static function parseCalendarDataFromMultistatus(string $xml): array
{
$doc = self::parseXml($xml);
if ($doc === null) return [];
$doc->registerXPathNamespace('d', 'DAV:');
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$results = [];
$responses = $doc->xpath('//d:response');
if (!$responses) return [];
foreach ($responses as $resp) {
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$caldata = $resp->xpath('.//cal:calendar-data');
if ($caldata && count($caldata) > 0) {
$data = trim((string)$caldata[0]);
if ($data !== '') {
$results[] = $data;
}
}
}
return $results;
}
/**
* Parse an XML string safely.
*
* @param string $xml
* @return \SimpleXMLElement|null
*/
protected static function parseXml(string $xml): ?\SimpleXMLElement
{
if (trim($xml) === '') return null;
// Disable external entity loading for security
$prev = libxml_use_internal_errors(true);
try {
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
libxml_clear_errors();
return ($doc !== false) ? $doc : null;
} finally {
libxml_use_internal_errors($prev);
}
}
/**
* Perform an HTTP request to a CalDAV server.
*
* @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.)
* @param string $url Full URL
* @param string $username
* @param string $password
* @param string $body Request body (empty for GET)
* @param string[] $headers Additional HTTP headers
* @return string|null Response body, or null on failure
*/
protected static function request(
string $method,
string $url,
string $username,
string $password,
string $body = '',
array $headers = []
): ?string {
if (!function_exists('curl_init')) return null;
$ch = curl_init();
if ($ch === false) return null;
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
// Authentication
if ($username !== '') {
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
}
// Request body
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
// HTTP headers
if ($headers !== []) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
// Capture HTTP status code
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!is_string($responseBody)) return null;
// Accept 2xx and 207 (multistatus) responses
if ($httpCode >= 200 && $httpCode < 300) {
return $responseBody;
}
if ($httpCode === 207) {
return $responseBody;
}
return null;
}
}

86
src/CalendarEvent.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Normalized calendar event/task for internal use.
*
* All calendar data (from any slot, any source) is converted into this
* structure before rendering or querying.
*/
class CalendarEvent
{
/** @var string Calendar slot key (e.g. 'general', 'maintenance') */
public $slotKey;
/** @var string Unique source event UID */
public $uid;
/** @var string Recurrence ID (empty for non-recurring or master) */
public $recurrenceId;
/** @var string Event summary/title */
public $summary;
/** @var string ISO 8601 start date/time */
public $startIso;
/** @var string ISO 8601 end date/time (may be empty) */
public $endIso;
/** @var bool Whether this is an all-day event */
public $allDay;
/** @var string Formatted time string (HH:MM) or empty for all-day */
public $time;
/** @var string Location (may be empty) */
public $location;
/** @var string Description (may be empty) */
public $description;
/**
* Status: empty, CONFIRMED, TENTATIVE, CANCELLED, TODO, COMPLETED,
* IN-PROCESS, NEEDS-ACTION.
* @var string
*/
public $status;
/** @var string Component type from source: VEVENT or VTODO */
public $componentType;
/** @var string The date (YYYY-MM-DD) this event applies to */
public $dateIso;
/**
* Build a stable completion key for maintenance task tracking.
*
* @return string
*/
public function completionKey(): string
{
return implode('|', [$this->slotKey, $this->uid, $this->dateIso]);
}
/**
* Whether this event/task is marked as completed.
*
* @return bool
*/
public function isCompleted(): bool
{
$s = strtoupper($this->status);
return $s === 'COMPLETED';
}
/**
* Whether this event/task is open (for maintenance filtering).
*
* @return bool
*/
public function isOpen(): bool
{
return !$this->isCompleted();
}
}

553
src/CalendarService.php Normal file
View File

@@ -0,0 +1,553 @@
<?php
namespace dokuwiki\plugin\luxtools;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
/**
* Slot-aware calendar service.
*
* Provides normalized event data grouped by slot for rendering,
* widget indicators, task list queries, and completion tracking.
*/
class CalendarService
{
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
protected static $dayCache = [];
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
protected static $taskCache = [];
/**
* Get all normalized events for a given date across all enabled slots.
*
* @param CalendarSlot[] $slots Keyed by slot key
* @param string $dateIso YYYY-MM-DD
* @return CalendarEvent[] Sorted: all-day first, then by time, then by title
*/
public static function eventsForDate(array $slots, string $dateIso): array
{
if (!ChronoID::isIsoDate($dateIso)) return [];
$all = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
$events = self::slotEventsForDate($slot, $dateIso);
foreach ($events as $event) {
$all[] = $event;
}
}
usort($all, [self::class, 'compareEvents']);
return $all;
}
/**
* Get events for a specific slot and date.
*
* @param CalendarSlot $slot
* @param string $dateIso
* @return CalendarEvent[]
*/
public static function slotEventsForDate(CalendarSlot $slot, string $dateIso): array
{
if (!ChronoID::isIsoDate($dateIso)) return [];
if (!$slot->isEnabled()) return [];
$cacheKey = $slot->getKey() . '|' . $dateIso;
if (isset(self::$dayCache[$cacheKey])) {
return self::$dayCache[$cacheKey];
}
$events = [];
$file = $slot->getFile();
if ($file !== '' && is_file($file) && is_readable($file)) {
$events = self::parseEventsFromFile($file, $slot->getKey(), $dateIso);
}
self::$dayCache[$cacheKey] = $events;
return $events;
}
/**
* Get events for a specific slot on a date, grouped by slot key.
*
* @param CalendarSlot[] $slots
* @param string $dateIso
* @return array<string,CalendarEvent[]> Keyed by slot key
*/
public static function eventsForDateGrouped(array $slots, string $dateIso): array
{
$grouped = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
$events = self::slotEventsForDate($slot, $dateIso);
if ($events !== []) {
$grouped[$slot->getKey()] = $events;
}
}
return $grouped;
}
/**
* Check whether a slot has any events on a given date.
*
* @param CalendarSlot $slot
* @param string $dateIso
* @return bool
*/
public static function slotHasEventsOnDate(CalendarSlot $slot, string $dateIso): bool
{
return self::slotEventsForDate($slot, $dateIso) !== [];
}
/**
* Get all open maintenance tasks due up to (and including) today.
*
* @param CalendarSlot $maintenanceSlot
* @param string $todayIso YYYY-MM-DD
* @return CalendarEvent[] Sorted: overdue first, then today, then by title
*/
public static function openMaintenanceTasks(CalendarSlot $maintenanceSlot, string $todayIso): array
{
if (!$maintenanceSlot->isEnabled()) return [];
if (!ChronoID::isIsoDate($todayIso)) return [];
$file = $maintenanceSlot->getFile();
if ($file === '' || !is_file($file) || !is_readable($file)) return [];
$cacheKey = $maintenanceSlot->getKey() . '|tasks|' . $todayIso;
if (isset(self::$taskCache[$cacheKey])) {
return self::$taskCache[$cacheKey];
}
$tasks = self::parseAllTasksFromFile($file, $maintenanceSlot->getKey(), $todayIso);
// Filter: only non-completed, due today or earlier
$open = [];
foreach ($tasks as $task) {
if ($task->isCompleted()) continue;
// dateIso is the date the task falls on
if ($task->dateIso > $todayIso) continue;
$open[] = $task;
}
// Sort: overdue first, then today, then by time, then by title
usort($open, static function (CalendarEvent $a, CalendarEvent $b) use ($todayIso): int {
$aOverdue = $a->dateIso < $todayIso;
$bOverdue = $b->dateIso < $todayIso;
if ($aOverdue !== $bOverdue) {
return $aOverdue ? -1 : 1;
}
$dateCmp = strcmp($a->dateIso, $b->dateIso);
if ($dateCmp !== 0) return $dateCmp;
$timeCmp = strcmp($a->time, $b->time);
if ($timeCmp !== 0) return $timeCmp;
return strcmp($a->summary, $b->summary);
});
self::$taskCache[$cacheKey] = $open;
return $open;
}
/**
* Get slot-level day indicator data for a whole month.
*
* @param CalendarSlot[] $slots
* @param int $year
* @param int $month
* @return array<string,string[]> date => [slotKey, ...]
*/
public static function monthIndicators(array $slots, int $year, int $month): array
{
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
$indicators = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
if (self::slotHasEventsOnDate($slot, $dateIso)) {
$indicators[$dateIso][] = $slot->getKey();
}
}
}
return $indicators;
}
/**
* Parse events from a local ICS file for a specific date.
*
* @param string $file
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent[]
*/
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
} catch (Throwable $e) {
return [];
}
}
/**
* Parse all tasks (VEVENT with STATUS or VTODO) from a maintenance file,
* expanding recurrences up to the given date.
*
* @param string $file
* @param string $slotKey
* @param string $todayIso
* @return CalendarEvent[]
*/
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
// Expand from a reasonable lookback to tomorrow
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable('2020-01-01 00:00:00', $utc);
$rangeEnd = new DateTimeImmutable($todayIso . ' 00:00:00', $utc);
$rangeEnd = $rangeEnd->add(new DateInterval('P1D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
$tasks = [];
// Collect VEVENTs
foreach ($expanded->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEvent($vevent, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
// Collect VTODOs
foreach ($expanded->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
return $tasks;
} catch (Throwable $e) {
return [];
}
}
/**
* Collect normalized events from an expanded VCalendar for a specific date.
*
* @param VCalendar $calendar
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent[]
*/
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array
{
$result = [];
$seen = [];
// VEVENTs
foreach ($calendar->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso);
if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = $event;
}
// VTODOs
foreach ($calendar->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodoForDay($vtodo, $slotKey, $dateIso);
if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = $event;
}
usort($result, [self::class, 'compareEvents']);
return $result;
}
/**
* Normalize a VEVENT for a specific day into a CalendarEvent.
*
* @param VEvent $vevent
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent|null
*/
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent
{
if (!isset($vevent->DTSTART)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = self::resolveEnd($vevent, $start, $isAllDay);
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vevent->UID ?? ''));
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $start->format(DateTimeInterface::ATOM);
$event->endIso = $end->format(DateTimeInterface::ATOM);
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $start->format('H:i');
$event->location = trim((string)($vevent->LOCATION ?? ''));
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
$event->componentType = 'VEVENT';
$event->dateIso = $dateIso;
return $event;
}
/**
* Normalize a VEVENT into a CalendarEvent (without day filtering).
*
* @param VEvent $vevent
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVEvent(VEvent $vevent, string $slotKey): ?CalendarEvent
{
if (!isset($vevent->DTSTART)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = self::resolveEnd($vevent, $start, $isAllDay);
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vevent->UID ?? ''));
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $start->format(DateTimeInterface::ATOM);
$event->endIso = $end->format(DateTimeInterface::ATOM);
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $start->format('H:i');
$event->location = trim((string)($vevent->LOCATION ?? ''));
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
$event->componentType = 'VEVENT';
$event->dateIso = $start->format('Y-m-d');
return $event;
}
/**
* Normalize a VTODO for a specific day.
*
* @param VTodo $vtodo
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent|null
*/
protected static function normalizeVTodoForDay(VTodo $vtodo, string $slotKey, string $dateIso): ?CalendarEvent
{
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event === null) return null;
// Check if the VTODO's due/start date matches this day
if ($event->dateIso !== $dateIso) return null;
return $event;
}
/**
* Normalize a VTODO into a CalendarEvent.
*
* @param VTodo $vtodo
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVTodo(VTodo $vtodo, string $slotKey): ?CalendarEvent
{
// VTODO uses DUE or DTSTART for date
$dateProperty = $vtodo->DUE ?? $vtodo->DTSTART ?? null;
if ($dateProperty === null) return null;
$isAllDay = strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$dt = self::toImmutable($dateProperty->getDateTime());
if ($dt === null) return null;
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vtodo->UID ?? ''));
$event->recurrenceId = isset($vtodo->{'RECURRENCE-ID'}) ? trim((string)$vtodo->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vtodo->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $dt->format(DateTimeInterface::ATOM);
$event->endIso = '';
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $dt->format('H:i');
$event->location = trim((string)($vtodo->LOCATION ?? ''));
$event->description = trim((string)($vtodo->DESCRIPTION ?? ''));
$status = strtoupper(trim((string)($vtodo->STATUS ?? '')));
// Map VTODO statuses to our model
if ($status === 'COMPLETED') {
$event->status = 'COMPLETED';
} elseif ($status === 'IN-PROCESS' || $status === 'NEEDS-ACTION' || $status === '') {
$event->status = 'TODO';
} else {
$event->status = $status;
}
$event->componentType = 'VTODO';
$event->dateIso = $dt->format('Y-m-d');
return $event;
}
/**
* Resolve the end date/time for a VEVENT.
*
* @param VEvent $vevent
* @param DateTimeImmutable $start
* @param bool $isAllDay
* @return DateTimeImmutable
*/
protected static function resolveEnd(VEvent $vevent, DateTimeImmutable $start, bool $isAllDay): DateTimeImmutable
{
if (isset($vevent->DTEND)) {
$end = self::toImmutable($vevent->DTEND->getDateTime());
if ($end !== null) return $end;
}
if (isset($vevent->DURATION)) {
try {
$duration = $vevent->DURATION->getDateInterval();
if ($duration instanceof DateInterval) {
return $start->add($duration);
}
} catch (Throwable $e) {
// fall through
}
}
return $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
/**
* Check if a date range intersects a given day.
*
* @param DateTimeImmutable $start
* @param DateTimeImmutable $end
* @param bool $isAllDay
* @param string $dateIso
* @return bool
*/
protected static function intersectsDay(
DateTimeImmutable $start,
DateTimeImmutable $end,
bool $isAllDay,
string $dateIso
): bool {
$eventTimezone = $start->getTimezone();
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
$dayEnd = $dayStart->add(new DateInterval('P1D'));
if ($end <= $start) {
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
$intersects = ($start < $dayEnd) && ($end > $dayStart);
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
$intersects = true;
}
return $intersects;
}
/**
* Compare two CalendarEvents for sorting.
*
* @param CalendarEvent $a
* @param CalendarEvent $b
* @return int
*/
protected static function compareEvents(CalendarEvent $a, CalendarEvent $b): int
{
if ($a->allDay !== $b->allDay) {
return $a->allDay ? -1 : 1;
}
$timeCmp = strcmp($a->time, $b->time);
if ($timeCmp !== 0) return $timeCmp;
return strcmp($a->summary, $b->summary);
}
/**
* Convert a DateTimeInterface to DateTimeImmutable.
*
* @param DateTimeInterface $dt
* @return DateTimeImmutable|null
*/
protected static function toImmutable(DateTimeInterface $dt): ?DateTimeImmutable
{
if ($dt instanceof DateTimeImmutable) return $dt;
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dt->getTimestamp());
if (!($immutable instanceof DateTimeImmutable)) return null;
return $immutable->setTimezone($dt->getTimezone());
}
/**
* Clear all runtime caches.
*/
public static function clearCache(): void
{
self::$dayCache = [];
self::$taskCache = [];
}
}

171
src/CalendarSlot.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Represents one calendar slot configuration.
*
* Each slot has a stable key, a human-readable label, local/remote source
* configuration, a display color, and a derived enabled state.
*/
class CalendarSlot
{
/** @var string[] Ordered list of all supported slot keys */
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4'];
/** @var array<string,string> Human-readable labels for slot keys */
public const SLOT_LABELS = [
'general' => 'General',
'maintenance' => 'Maintenance',
'slot3' => 'Slot 3',
'slot4' => 'Slot 4',
];
/** @var string */
protected $key;
/** @var string */
protected $label;
/** @var string Local ICS file path */
protected $file;
/** @var string CalDAV URL */
protected $caldavUrl;
/** @var string CalDAV username */
protected $username;
/** @var string CalDAV password */
protected $password;
/** @var string CSS color for widget indicators */
protected $color;
/**
* @param string $key
* @param string $file
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $color
*/
public function __construct(
string $key,
string $file = '',
string $caldavUrl = '',
string $username = '',
string $password = '',
string $color = ''
) {
$this->key = $key;
$this->label = self::SLOT_LABELS[$key] ?? $key;
$this->file = trim($file);
$this->caldavUrl = trim($caldavUrl);
$this->username = trim($username);
$this->password = trim($password);
$this->color = trim($color);
}
public function getKey(): string
{
return $this->key;
}
public function getLabel(): string
{
return $this->label;
}
public function getFile(): string
{
return $this->file;
}
public function getCaldavUrl(): string
{
return $this->caldavUrl;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
public function getColor(): string
{
return $this->color;
}
/**
* A slot is enabled if it has a local file path or a CalDAV URL.
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->file !== '' || $this->caldavUrl !== '';
}
/**
* Whether this slot has a usable local ICS file.
*
* @return bool
*/
public function hasLocalFile(): bool
{
if ($this->file === '') return false;
return is_file($this->file) && is_readable($this->file);
}
/**
* Whether this slot has a configured remote CalDAV source.
*
* @return bool
*/
public function hasRemoteSource(): bool
{
return $this->caldavUrl !== '';
}
/**
* Load all configured calendar slots from plugin configuration.
*
* @param object $plugin Plugin instance with getConf() method
* @return CalendarSlot[] Keyed by slot key
*/
public static function loadAll($plugin): array
{
$slots = [];
foreach (self::SLOT_KEYS as $key) {
$slots[$key] = new self(
$key,
(string)$plugin->getConf('calendar_' . $key . '_file'),
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
(string)$plugin->getConf('calendar_' . $key . '_username'),
(string)$plugin->getConf('calendar_' . $key . '_password'),
(string)$plugin->getConf('calendar_' . $key . '_color')
);
}
return $slots;
}
/**
* Load only enabled calendar slots.
*
* @param object $plugin Plugin instance with getConf() method
* @return CalendarSlot[] Keyed by slot key
*/
public static function loadEnabled($plugin): array
{
$all = self::loadAll($plugin);
return array_filter($all, static function (CalendarSlot $slot): bool {
return $slot->isEnabled();
});
}
}

View File

@@ -13,10 +13,17 @@ class ChronologicalCalendarWidget
* @param int $year * @param int $year
* @param int $month * @param int $month
* @param string $baseNs * @param string $baseNs
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
* @param array<string,string> $slotColors slotKey => CSS color
* @return string * @return string
*/ */
public static function render(int $year, int $month, string $baseNs = 'chronological'): string public static function render(
{ int $year,
int $month,
string $baseNs = 'chronological',
array $indicators = [],
array $slotColors = []
): string {
if (!self::isValidMonth($year, $month)) return ''; if (!self::isValidMonth($year, $month)) return '';
$firstDayTs = mktime(0, 0, 0, $month, 1, $year); $firstDayTs = mktime(0, 0, 0, $month, 1, $year);
@@ -109,6 +116,19 @@ class ChronologicalCalendarWidget
$classes = 'luxtools-calendar-day'; $classes = 'luxtools-calendar-day';
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">'; $html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
// Render slot indicators if any
$dayIndicators = $indicators[$date] ?? [];
if ($dayIndicators !== []) {
$html .= '<div class="luxtools-calendar-indicators">';
foreach ($dayIndicators as $slotKey) {
$color = $slotColors[$slotKey] ?? '';
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
$html .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($slotKey) . '"' . $style . '></span>';
}
$html .= '</div>';
}
if ($dayId !== null && function_exists('html_wikilink')) { if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber); $html .= (string)html_wikilink($dayId, (string)$dayNumber);
} else { } else {

434
src/IcsWriter.php Normal file
View File

@@ -0,0 +1,434 @@
<?php
namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
/**
* Write-back support for local ICS files.
*
* Handles updating event/task status (completion, reopening) in local
* ICS files while preserving the original component type and other properties.
*/
class IcsWriter
{
/**
* Update the STATUS of an event or task occurrence in a local ICS file.
*
* For VEVENT: sets STATUS to the given value (TODO or COMPLETED).
* For VTODO: sets STATUS and, when completing, sets COMPLETED timestamp;
* when reopening, removes the COMPLETED property.
*
* For recurring events, this writes an override/exception for the specific
* occurrence rather than modifying the master event.
*
* @param string $filePath Absolute path to the local ICS file
* @param string $uid Event UID
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
* @param string $dateIso Occurrence date YYYY-MM-DD (for recurring event identification)
* @return bool True if the file was updated successfully
*/
public static function updateEventStatus(
string $filePath,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
if ($uid === '' || $filePath === '') return false;
if (!is_file($filePath) || !is_writable($filePath)) return false;
$raw = @file_get_contents($filePath);
if (!is_string($raw) || trim($raw) === '') return false;
try {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
$updated = self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
if (!$updated) return false;
$output = $calendar->serialize();
return self::atomicWrite($filePath, $output);
} catch (Throwable $e) {
return false;
}
}
/**
* Apply a status update to the matching component in the calendar.
*
* Public alias for use by CalDavClient when modifying remote calendar data.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
public static function applyStatusUpdateToCalendar(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
return self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
}
/**
* Apply a status update to the matching component in the calendar.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
protected static function applyStatusUpdate(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
// Try VEVENT first
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVEventStatus($component, $newStatus);
return true;
}
}
// Try VTODO
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVTodoStatus($component, $newStatus);
return true;
}
}
// For recurring events without a matching override, create one
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
// This is the master event; check if it has RRULE (recurring)
if (!isset($component->RRULE)) continue;
// Create an occurrence override
$override = self::createOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
if ($override !== null) return true;
}
// Same for VTODO with RRULE
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
if (!isset($component->RRULE)) continue;
$override = self::createVTodoOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
if ($override !== null) return true;
}
return false;
}
/**
* Check if a component matches the given UID and recurrence criteria.
*
* @param VEvent|VTodo $component
* @param string $uid
* @param string $recurrenceId
* @param string $dateIso
* @return bool
*/
protected static function matchesComponent($component, string $uid, string $recurrenceId, string $dateIso): bool
{
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) return false;
// If we have a specific recurrence ID, match it
if ($recurrenceId !== '') {
$componentRid = isset($component->{'RECURRENCE-ID'}) ? trim((string)$component->{'RECURRENCE-ID'}) : '';
return $componentRid === $recurrenceId;
}
// For non-recurring events (no RRULE, no RECURRENCE-ID), match by UID alone
if (!isset($component->RRULE) && !isset($component->{'RECURRENCE-ID'})) {
return true;
}
// For a specific occurrence override already in the file
if (isset($component->{'RECURRENCE-ID'})) {
$ridDt = $component->{'RECURRENCE-ID'}->getDateTime();
if ($ridDt !== null && $ridDt->format('Y-m-d') === $dateIso) {
return true;
}
}
return false;
}
/**
* Set the STATUS property on a VEVENT.
*
* @param VEvent $vevent
* @param string $newStatus
*/
protected static function setVEventStatus(VEvent $vevent, string $newStatus): void
{
$vevent->STATUS = $newStatus;
}
/**
* Set the STATUS property on a VTODO with native completion semantics.
*
* @param VTodo $vtodo
* @param string $newStatus
*/
protected static function setVTodoStatus(VTodo $vtodo, string $newStatus): void
{
if ($newStatus === 'COMPLETED') {
$vtodo->STATUS = 'COMPLETED';
// Set COMPLETED timestamp per RFC 5545
$vtodo->COMPLETED = gmdate('Ymd\THis\Z');
// Set PERCENT-COMPLETE to 100
$vtodo->{'PERCENT-COMPLETE'} = '100';
} else {
// Reopening
$vtodo->STATUS = 'NEEDS-ACTION';
// Remove COMPLETED timestamp
if (isset($vtodo->COMPLETED)) {
unset($vtodo->COMPLETED);
}
if (isset($vtodo->{'PERCENT-COMPLETE'})) {
unset($vtodo->{'PERCENT-COMPLETE'});
}
}
}
/**
* Create an occurrence override for a recurring VEVENT.
*
* @param VCalendar $calendar
* @param VEvent $master
* @param string $newStatus
* @param string $dateIso
* @return VEvent|null
*/
protected static function createOccurrenceOverride(
VCalendar $calendar,
VEvent $master,
string $newStatus,
string $dateIso
): ?VEvent {
try {
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
'STATUS' => $newStatus,
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
// Set VALUE=DATE on the properties
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['VALUE'] = 'DATE';
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
// Use the master's time for the occurrence
$masterStart = $master->DTSTART->getDateTime();
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['TZID'] = $tz->getName();
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
}
}
// Copy duration or DTEND if present
if (isset($master->DTEND)) {
$duration = $master->DTSTART->getDateTime()->diff($master->DTEND->getDateTime());
$startDt = $override->DTSTART->getDateTime();
$endDt = $startDt->add($duration);
if ($isAllDay) {
$override->add('DTEND', $endDt->format('Ymd'));
$override->DTEND['VALUE'] = 'DATE';
} else {
$override->add('DTEND', $endDt);
}
} elseif (isset($master->DURATION)) {
$override->DURATION = (string)$master->DURATION;
}
// Copy LOCATION and DESCRIPTION if present
if (isset($master->LOCATION)) {
$override->LOCATION = (string)$master->LOCATION;
}
if (isset($master->DESCRIPTION)) {
$override->DESCRIPTION = (string)$master->DESCRIPTION;
}
return $override;
} catch (Throwable $e) {
return null;
}
}
/**
* Create an occurrence override for a recurring VTODO.
*
* @param VCalendar $calendar
* @param VTodo $master
* @param string $newStatus
* @param string $dateIso
* @return VTodo|null
*/
protected static function createVTodoOccurrenceOverride(
VCalendar $calendar,
VTodo $master,
string $newStatus,
string $dateIso
): ?VTodo {
try {
$dateProperty = $master->DUE ?? $master->DTSTART ?? null;
$isAllDay = $dateProperty !== null && strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['VALUE'] = 'DATE';
}
if (isset($override->DUE)) {
$override->DUE['VALUE'] = 'DATE';
}
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
$dt = $dateProperty->getDateTime();
$recurrenceValue = $dateIso . 'T' . $dt->format('His');
$tz = $dt->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['TZID'] = $tz->getName();
}
if (isset($override->DUE)) {
$override->DUE['TZID'] = $tz->getName();
}
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
}
}
self::setVTodoStatus($override, $newStatus);
if (isset($master->LOCATION)) {
$override->LOCATION = (string)$master->LOCATION;
}
if (isset($master->DESCRIPTION)) {
$override->DESCRIPTION = (string)$master->DESCRIPTION;
}
return $override;
} catch (Throwable $e) {
return null;
}
}
/**
* Public atomic file write for use by CalDavClient sync.
*
* @param string $filePath
* @param string $content
* @return bool
*/
public static function atomicWritePublic(string $filePath, string $content): bool
{
$dir = dirname($filePath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::atomicWrite($filePath, $content);
}
/**
* Atomic file write using a temp file and rename.
*
* @param string $filePath
* @param string $content
* @return bool
*/
protected static function atomicWrite(string $filePath, string $content): bool
{
$dir = dirname($filePath);
$tmpFile = $dir . '/.luxtools_tmp_' . getmypid() . '_' . mt_rand();
if (@file_put_contents($tmpFile, $content, LOCK_EX) === false) {
@unlink($tmpFile);
return false;
}
if (!@rename($tmpFile, $filePath)) {
@unlink($tmpFile);
return false;
}
return true;
}
}

238
style.css
View File

@@ -646,3 +646,241 @@ div.luxtools-calendar td.luxtools-calendar-day:hover {
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover { div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
background-color: @ini_highlight; background-color: @ini_highlight;
} }
/* ============================================================
* Calendar Widget Indicators
* Colored corner markers showing which slots have events on a day.
* Positions: general=top-left, maintenance=top-right,
* slot3=bottom-right, slot4=bottom-left (clockwise)
* ============================================================ */
div.luxtools-calendar td.luxtools-calendar-day {
position: relative;
}
.luxtools-calendar-indicators {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.luxtools-calendar-indicator {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
}
.luxtools-indicator-general {
top: 2px;
left: 2px;
}
.luxtools-indicator-maintenance {
top: 2px;
right: 2px;
}
.luxtools-indicator-slot3 {
bottom: 2px;
right: 2px;
}
.luxtools-indicator-slot4 {
bottom: 2px;
left: 2px;
}
/* ============================================================
* Chronological Events on Day Pages
* ============================================================ */
div.luxtools-chronological-events ul {
list-style: none;
padding-left: 0;
margin: 0.5em 0;
}
div.luxtools-chronological-events li {
padding: 0.35em 0.5em;
margin: 0.25em 0;
border-left: 3px solid @ini_border;
cursor: pointer;
}
div.luxtools-chronological-events li:hover {
background-color: @ini_background_alt;
}
div.luxtools-chronological-events li[data-luxtools-event] .luxtools-event-time {
font-weight: bold;
margin-right: 0.25em;
}
/* ============================================================
* Maintenance Tasks
* ============================================================ */
div.luxtools-chronological-maintenance li {
border-left-color: #e67e22;
}
li.luxtools-maintenance-task.luxtools-task-completed {
opacity: 0.5;
text-decoration: line-through;
}
button.luxtools-task-action,
button.luxtools-task-complete-btn {
margin-left: 0.5em;
padding: 0.15em 0.5em;
font-size: 0.85em;
border: 1px solid @ini_border;
border-radius: 0.2em;
background-color: @ini_background_alt;
cursor: pointer;
}
button.luxtools-task-action:hover,
button.luxtools-task-complete-btn:hover {
background-color: @ini_highlight;
}
button.luxtools-task-action:disabled,
button.luxtools-task-complete-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/* ============================================================
* Maintenance Task List (syntax plugin)
* ============================================================ */
div.luxtools-maintenance-tasks {
margin: 1em 0;
}
ul.luxtools-maintenance-task-list {
list-style: none;
padding-left: 0;
}
ul.luxtools-maintenance-task-list li {
padding: 0.35em 0.5em;
margin: 0.25em 0;
border-left: 3px solid #e67e22;
}
li.luxtools-task-overdue .luxtools-task-date {
color: #c0392b;
font-weight: bold;
}
.luxtools-task-date {
font-family: monospace;
margin-right: 0.5em;
}
.luxtools-task-time {
font-weight: bold;
margin-right: 0.25em;
}
.luxtools-maintenance-task-item.luxtools-task-completed {
opacity: 0.5;
text-decoration: line-through;
}
/* ============================================================
* Event Popup
* ============================================================ */
.luxtools-event-popup-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 10000;
justify-content: center;
align-items: center;
}
.luxtools-event-popup {
background: @ini_background;
border: 1px solid @ini_border;
border-radius: 0.4em;
padding: 1.5em;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.luxtools-event-popup-close {
position: absolute;
top: 0.5em;
right: 0.75em;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: @ini_text;
line-height: 1;
}
.luxtools-event-popup-close:hover {
opacity: 0.7;
}
.luxtools-event-popup-title {
margin: 0 0 0.75em 0;
padding-right: 1.5em;
}
.luxtools-event-popup-field {
margin: 0.5em 0;
}
.luxtools-event-popup-description {
white-space: pre-wrap;
word-break: break-word;
}
.luxtools-event-popup-slot {
margin-top: 1em;
opacity: 0.6;
font-size: 0.9em;
}
/* ============================================================
* Notifications (fallback)
* ============================================================ */
.luxtools-notification {
position: fixed;
top: 1em;
right: 1em;
z-index: 10001;
padding: 0.75em 1em;
border-radius: 0.3em;
font-size: 0.9em;
max-width: 400px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.luxtools-notification-error {
background: #c0392b;
color: #fff;
}
.luxtools-notification-warning {
background: #e67e22;
color: #fff;
}

View File

@@ -1,6 +1,8 @@
<?php <?php
use dokuwiki\Extension\SyntaxPlugin; use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\CalendarService;
use dokuwiki\plugin\luxtools\CalendarSlot;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget; use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
require_once(__DIR__ . '/../autoload.php'); require_once(__DIR__ . '/../autoload.php');
@@ -85,7 +87,18 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$month = (int)$data['month']; $month = (int)$data['month'];
$baseNs = (string)$data['base']; $baseNs = (string)$data['base'];
$renderer->doc .= 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;
}
}
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
return true; return true;
} }

129
syntax/maintenance.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\CalendarService;
use dokuwiki\plugin\luxtools\CalendarSlot;
use dokuwiki\plugin\luxtools\ChronoID;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Maintenance task list syntax.
*
* Renders a list of all non-completed maintenance tasks due today or earlier.
*
* Syntax:
* {{maintenance_tasks>}}
*/
class syntax_plugin_luxtools_maintenance extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 225;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern(
'\{\{maintenance_tasks>\}\}',
$mode,
'plugin_luxtools_maintenance'
);
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
return ['ok' => true];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if ($data === false || !is_array($data)) return false;
if ($format !== 'xhtml') return false;
if (!($renderer instanceof Doku_Renderer_xhtml)) return false;
$renderer->nocache();
$slots = CalendarSlot::loadAll($this);
$maintenanceSlot = $slots['maintenance'] ?? null;
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
$renderer->doc .= '<div class="luxtools-plugin luxtools-maintenance-tasks">'
. '<p class="luxtools-empty">'
. hsc($this->getLang('maintenance_no_tasks'))
. '</p></div>';
return true;
}
$todayIso = date('Y-m-d');
$tasks = CalendarService::openMaintenanceTasks($maintenanceSlot, $todayIso);
$title = (string)$this->getLang('chronological_maintenance_title');
if ($title === '') $title = 'Tasks';
$renderer->doc .= '<div class="luxtools-plugin luxtools-maintenance-tasks">';
$renderer->doc .= '<h3>' . hsc($title) . '</h3>';
if ($tasks === []) {
$noTasks = (string)$this->getLang('maintenance_no_tasks');
if ($noTasks === '') $noTasks = 'No open tasks.';
$renderer->doc .= '<p class="luxtools-empty">' . hsc($noTasks) . '</p>';
} else {
$renderer->doc .= '<ul class="luxtools-maintenance-task-list">';
foreach ($tasks as $task) {
$overdue = ($task->dateIso < $todayIso);
$classes = 'luxtools-maintenance-task-item';
if ($overdue) {
$classes .= ' luxtools-task-overdue';
}
$renderer->doc .= '<li class="' . $classes . '"';
$renderer->doc .= ' data-uid="' . hsc($task->uid) . '"';
$renderer->doc .= ' data-date="' . hsc($task->dateIso) . '"';
$renderer->doc .= ' data-recurrence="' . hsc($task->recurrenceId) . '"';
$renderer->doc .= '>';
// Date badge
$renderer->doc .= '<span class="luxtools-task-date">' . hsc($task->dateIso) . '</span> ';
// Time if not all-day
if ($task->time !== '') {
$renderer->doc .= '<span class="luxtools-task-time">' . hsc($task->time) . '</span> ';
}
// Summary
$renderer->doc .= '<span class="luxtools-task-summary">' . hsc($task->summary) . '</span>';
// Complete button
$completeLabel = (string)$this->getLang('maintenance_task_complete');
if ($completeLabel === '') $completeLabel = 'Complete';
$renderer->doc .= ' <button class="luxtools-task-complete-btn" type="button"'
. ' data-action="complete"'
. '>' . hsc($completeLabel) . '</button>';
$renderer->doc .= '</li>';
}
$renderer->doc .= '</ul>';
}
$renderer->doc .= '</div>';
return true;
}
}