Compare commits

...

5 Commits

Author SHA1 Message Date
97d9647ea2 Fix timezone issue in widget 2026-03-11 13:35:31 +01:00
6162ff595f Calendar Refinement 2026-03-11 13:31:49 +01:00
94215fdd65 Calendar V3 2026-03-11 13:15:20 +01:00
a4815fc672 Calendar Sync V2 2026-03-11 12:18:02 +01:00
2d5e9541c2 Calendar Sync V1 2026-03-11 11:44:37 +01:00
19 changed files with 3317 additions and 83 deletions

118
README.md
View File

@@ -163,13 +163,27 @@ 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
- **Display**: Where to show that slot's calendar indicator (`None`, `Top Left`, `Top Right`, `Bottom Left`, `Bottom Right`)
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.
@@ -275,18 +289,27 @@ Render a basic monthly calendar that links each day to canonical chronological p
``` ```
{{calendar>}} {{calendar>}}
{{calendar>2024-10}} {{calendar>2024-10}}
{{calendar>2026-03&size=small}}
{{calendar>2026-03&size=large&show_times=0}}
``` ```
Notes: Notes:
- `{{calendar>}}` renders the current month. - `{{calendar>}}` renders the current month.
- `{{calendar>YYYY-MM}}` renders a specific month. - `{{calendar>YYYY-MM}}` renders a specific month.
- `size=large|small` controls the widget layout and defaults to `large`.
- `show_times=1|0` controls inline event times in `large` mode and defaults to `1`.
- Day links target `chronological:YYYY:MM:DD`. - Day links target `chronological:YYYY:MM:DD`.
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`. - Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
- Prev/next month buttons update the widget in-place without a full page reload. - Prev/next month buttons update the widget in-place without a full page reload.
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node. - 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.
- Small mode keeps the compact day-number-plus-indicator layout.
- Large mode renders inline day events in the month cells and suppresses the corner indicators.
- Only slots whose `Display` setting is not `None` participate in widget visibility.
- Indicator placement in small mode is configured per slot via the `Display` setting.
- Slot colors are reused for both indicators and inline event accents.
### 0.4) Virtual chronological day pages ### 0.4) Virtual chronological day pages
@@ -297,7 +320,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 +338,84 @@ 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.
- 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.
### 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",
]; ];
@@ -138,6 +155,8 @@ class action_plugin_luxtools extends ActionPlugin
if ($baseNs === '') { if ($baseNs === '') {
$baseNs = 'chronological'; $baseNs = 'chronological';
} }
$size = ChronologicalCalendarWidget::normalizeSize((string)$INPUT->str('size'));
$showTimes = ChronologicalCalendarWidget::normalizeShowTimes($INPUT->str('show_times'));
if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) { if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) {
http_status(400); http_status(400);
@@ -147,7 +166,33 @@ class action_plugin_luxtools extends ActionPlugin
$this->sendNoStoreHeaders(); $this->sendNoStoreHeaders();
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs); $slots = CalendarSlot::loadEnabled($this);
$widgetSlots = CalendarSlot::filterWidgetVisible($slots);
$indicators = [];
$dayEvents = [];
if ($size === 'large') {
$widgetData = CalendarService::monthWidgetData($widgetSlots, $year, $month);
$indicators = $widgetData['indicators'];
$dayEvents = $widgetData['events'];
} else {
$indicators = CalendarService::monthIndicators($widgetSlots, $year, $month);
}
$slotColors = [];
$slotDisplays = [];
foreach ($widgetSlots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
}
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [
'size' => $size,
'showTimes' => $showTimes,
'dayEvents' => $dayEvents,
]);
if ($html === '') { if ($html === '') {
http_status(500); http_status(500);
echo 'Calendar rendering failed'; echo 'Calendar rendering failed';
@@ -458,50 +503,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 '';
$html = '';
// Render general events
if (isset($grouped['general'])) {
$title = (string)$this->getLang('chronological_events_title'); $title = (string)$this->getLang('chronological_events_title');
if ($title === '') $title = 'Events'; if ($title === '') $title = 'Events';
$html .= $this->renderEventSection($grouped['general'], $title, 'general');
}
// Render maintenance tasks
if (isset($grouped['maintenance'])) {
$title = (string)$this->getLang('chronological_maintenance_title');
if ($title === '') $title = 'Tasks';
$html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso);
}
// Render slot3/slot4 if present
foreach (['slot3', 'slot4'] as $slotKey) {
if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
$label = $slots[$slotKey]->getLabel();
$html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey);
}
}
return $html;
}
/**
* 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 = ''; $items = '';
foreach ($events as $entry) { foreach ($events as $event) {
$summary = trim((string)($entry['summary'] ?? '')); $items .= $this->renderEventListItem($event);
if ($summary === '') $summary = '(ohne Titel)';
$time = trim((string)($entry['time'] ?? ''));
$startIso = trim((string)($entry['startIso'] ?? ''));
$isAllDay = (bool)($entry['allDay'] ?? false);
if ($isAllDay || $time === '') {
$items .= '<li>' . hsc($summary) . '</li>';
} else {
$timeHtml = '<span class="luxtools-event-time"';
if ($startIso !== '') {
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
} }
$timeHtml .= '>' . hsc($time) . '</span>';
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
}
}
if ($items === '') return ''; if ($items === '') return '';
$html = '<ul>' . $items . '</ul>';
return '<div class="luxtools-plugin luxtools-chronological-events">' 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 {
$caldavResult = CalDavClient::updateEventStatus(
$maintenanceSlot->getCaldavUrl(),
$maintenanceSlot->getUsername(),
$maintenanceSlot->getPassword(),
$uid,
$recurrence,
$newStatus,
$dateIso
);
if ($caldavResult !== '') {
$remoteOk = false;
$remoteError = $this->getLang('maintenance_remote_write_failed') . ': ' . $caldavResult;
}
} catch (Throwable $e) {
$remoteOk = false;
$remoteError = $this->getLang('maintenance_remote_write_failed') . ': ' . $e->getMessage();
}
}
$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 +853,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,30 @@ 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_general_display',
'calendar_maintenance_file',
'calendar_maintenance_caldav_url',
'calendar_maintenance_username',
'calendar_maintenance_password',
'calendar_maintenance_color',
'calendar_maintenance_display',
'calendar_slot3_file',
'calendar_slot3_caldav_url',
'calendar_slot3_username',
'calendar_slot3_password',
'calendar_slot3_color',
'calendar_slot3_display',
'calendar_slot4_file',
'calendar_slot4_caldav_url',
'calendar_slot4_username',
'calendar_slot4_password',
'calendar_slot4_color',
'calendar_slot4_display',
'pagelink_search_depth', 'pagelink_search_depth',
]; ];
@@ -90,9 +116,15 @@ 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'));
$newConf['calendar_' . $slot . '_display'] = trim($INPUT->str('calendar_' . $slot . '_display'));
}
$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,12 +272,89 @@ 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',
'slot3' => 'Slot 3',
'slot4' => 'Slot 4',
];
$displayOptions = [
'none' => (string)$this->getLang('calendar_slot_display_none'),
'top-left' => (string)$this->getLang('calendar_slot_display_top_left'),
'top-right' => (string)$this->getLang('calendar_slot_display_top_right'),
'bottom-left' => (string)$this->getLang('calendar_slot_display_bottom_left'),
'bottom-right' => (string)$this->getLang('calendar_slot_display_bottom_right'),
];
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><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 />';
$currentDisplay = (string)$this->getConf($prefix . 'display');
if ($currentDisplay === '') $currentDisplay = 'none';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_display')) . '</span> ';
echo '<select name="' . hsc($prefix . 'display') . '" class="edit">';
foreach ($displayOptions as $value => $label) {
if ($label === '') $label = $value;
$selected = ($currentDisplay === $value) ? ' selected="selected"' : '';
echo '<option value="' . hsc($value) . '"' . $selected . '>' . hsc($label) . '</option>';
}
echo '</select>';
echo '</label><br />';
}
// CalDAV Sync button (outside the save form, separate action)
$ajaxUrl = DOKU_BASE . 'lib/exe/ajax.php';
$sectok = getSecurityToken();
echo '<div class="luxtools-admin-sync" style="margin: 1em 0;">';
echo '<button type="button" class="button" id="luxtools-sync-btn">'
. hsc($this->getLang('calendar_sync_button'))
. '</button>';
echo '<span id="luxtools-sync-status" style="margin-left: 1em;"></span>';
echo '</div>';
echo '<script>';
echo 'document.getElementById("luxtools-sync-btn").addEventListener("click", function() {';
echo ' var btn = this;';
echo ' var status = document.getElementById("luxtools-sync-status");';
echo ' btn.disabled = true;';
echo ' status.textContent = "Syncing...";';
echo ' var xhr = new XMLHttpRequest();';
echo ' xhr.open("POST", ' . json_encode($ajaxUrl) . ', true);';
echo ' xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");';
echo ' xhr.onload = function() {';
echo ' btn.disabled = false;';
echo ' try {';
echo ' var r = JSON.parse(xhr.responseText);';
echo ' status.textContent = r.message || (r.ok ? "Done" : "Failed");';
echo ' status.style.color = r.ok ? "green" : "red";';
echo ' } catch(e) { status.textContent = "Error"; status.style.color = "red"; }';
echo ' };';
echo ' xhr.onerror = function() { btn.disabled = false; status.textContent = "Network error"; status.style.color = "red"; };';
echo ' xhr.send("call=luxtools_calendar_sync&sectok=" + encodeURIComponent(' . json_encode($sectok) . '));';
echo '});';
echo '</script>';
// 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> ';
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />'; echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';

View File

@@ -37,8 +37,35 @@ $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, display
$conf['calendar_general_file'] = '';
$conf['calendar_general_caldav_url'] = '';
$conf['calendar_general_username'] = '';
$conf['calendar_general_password'] = '';
$conf['calendar_general_color'] = '#4a90d9';
$conf['calendar_general_display'] = 'none';
$conf['calendar_maintenance_file'] = '';
$conf['calendar_maintenance_caldav_url'] = '';
$conf['calendar_maintenance_username'] = '';
$conf['calendar_maintenance_password'] = '';
$conf['calendar_maintenance_color'] = '#e67e22';
$conf['calendar_maintenance_display'] = 'none';
$conf['calendar_slot3_file'] = '';
$conf['calendar_slot3_caldav_url'] = '';
$conf['calendar_slot3_username'] = '';
$conf['calendar_slot3_password'] = '';
$conf['calendar_slot3_color'] = '#27ae60';
$conf['calendar_slot3_display'] = 'none';
$conf['calendar_slot4_file'] = '';
$conf['calendar_slot4_caldav_url'] = '';
$conf['calendar_slot4_username'] = '';
$conf['calendar_slot4_password'] = '';
$conf['calendar_slot4_color'] = '#8e44ad';
$conf['calendar_slot4_display'] = 'none';
// 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;

View File

@@ -54,7 +54,12 @@
return 'luxtools.calendar.month.' + baseNs; return 'luxtools.calendar.month.' + baseNs;
} }
function shouldPersistCalendarMonth(calendar) {
return (calendar.getAttribute('data-luxtools-size') || 'large') === 'small';
}
function readSavedCalendarMonth(calendar) { function readSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return null;
if (!window.localStorage) return null; if (!window.localStorage) return null;
try { try {
@@ -73,6 +78,7 @@
} }
function saveCalendarMonth(calendar) { function saveCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return; if (!window.localStorage) return;
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10); var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
@@ -90,6 +96,7 @@
} }
function clearSavedCalendarMonth(calendar) { function clearSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return; if (!window.localStorage) return;
try { try {
@@ -104,11 +111,15 @@
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url')); if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological'; var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
var size = calendar.getAttribute('data-luxtools-size') || 'large';
var showTimes = calendar.getAttribute('data-luxtools-show-times') || '1';
var params = new URLSearchParams({ var params = new URLSearchParams({
call: 'luxtools_calendar_month', call: 'luxtools_calendar_month',
year: String(year), year: String(year),
month: String(month), month: String(month),
base: baseNs base: baseNs,
size: size,
show_times: showTimes
}); });
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString(); var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
@@ -161,6 +172,9 @@
} }
syncCalendarToday(replacement); syncCalendarToday(replacement);
calendar.replaceWith(replacement); calendar.replaceWith(replacement);
if (Luxtools.initChronologicalEventTimes) {
Luxtools.initChronologicalEventTimes();
}
} }
function loadCalendarMonth(calendar, targetYear, targetMonth, persistState) { function loadCalendarMonth(calendar, targetYear, targetMonth, persistState) {
@@ -212,6 +226,7 @@
} }
function restoreCalendarMonth(calendar) { function restoreCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
var saved = readSavedCalendarMonth(calendar); var saved = readSavedCalendarMonth(calendar);
if (!saved) return; if (!saved) return;

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

@@ -0,0 +1,310 @@
/* 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 && !isSameMoment(start, end)) {
html += ' &ndash; ' + formatDate(end);
}
} else {
html += '<strong>Time:</strong> ' + formatDateTime(start);
if (end && !isSameMoment(start, 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 pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear();
}
function formatDateTime(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return formatDate(isoStr) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
function isSameMoment(left, right) {
if (!left || !right) return false;
return left === right;
}
function pad2(value) {
return String(value).padStart(2, '0');
}
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');
button.textContent = 'Reopen';
button.setAttribute('data-action', 'reopen');
button.disabled = false;
} 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

@@ -86,9 +86,10 @@
var formatter; var formatter;
try { try {
formatter = new Intl.DateTimeFormat(undefined, { formatter = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
hour12: false
}); });
} catch (e) { } catch (e) {
formatter = null; formatter = null;
@@ -184,4 +185,6 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (Scratchpads && Scratchpads.init) Scratchpads.init(); if (Scratchpads && Scratchpads.init) Scratchpads.init();
}, false); }, false);
Luxtools.initChronologicalEventTimes = initChronologicalEventTimes;
})(); })();

View File

@@ -64,8 +64,18 @@ $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["calendar_slot_display"] = "Anzeige";
$lang["calendar_slot_display_none"] = "Keine";
$lang["calendar_slot_display_top_left"] = "Oben links";
$lang["calendar_slot_display_top_right"] = "Oben rechts";
$lang["calendar_slot_display_bottom_left"] = "Unten links";
$lang["calendar_slot_display_bottom_right"] = "Unten rechts";
$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 +95,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,18 @@ $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["calendar_slot_display"] = "Display";
$lang["calendar_slot_display_none"] = "None";
$lang["calendar_slot_display_top_left"] = "Top Left";
$lang["calendar_slot_display_top_right"] = "Top Right";
$lang["calendar_slot_display_bottom_left"] = "Bottom Left";
$lang["calendar_slot_display_bottom_right"] = "Bottom Right";
$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 +96,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.";

475
src/CalDavClient.php Normal file
View File

@@ -0,0 +1,475 @@
<?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;
/** @var string Last request error message for diagnostics */
protected static string $lastRequestError = '';
/**
* 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 string Empty string on success, error message on failure
*/
public static function updateEventStatus(
string $caldavUrl,
string $username,
string $password,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): string {
if ($caldavUrl === '' || $uid === '') return 'Missing CalDAV URL or UID';
try {
// Find the calendar object href for this UID via REPORT
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
if ($objectInfo === null) {
$msg = "CalDAV: Could not find object with UID '$uid' on server";
dbglog($msg);
return $msg;
}
$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)) {
$msg = "CalDAV: Failed to parse calendar data for UID '$uid'";
dbglog($msg);
return $msg;
}
$updated = IcsWriter::applyStatusUpdateToCalendar(
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
);
if (!$updated) {
$msg = "CalDAV: applyStatusUpdateToCalendar failed for UID '$uid'";
dbglog($msg);
return $msg;
}
$newData = $calendar->serialize();
// PUT the updated object back with If-Match for conflict detection
$putError = self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
if ($putError !== '') {
dbglog($putError);
return $putError;
}
return '';
} catch (Throwable $e) {
$msg = 'CalDAV: Exception during updateEventStatus: ' . $e->getMessage();
dbglog($msg);
return $msg;
}
}
/**
* 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) {
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 components
foreach ($cal->select('VEVENT') 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 string Empty string on success, error message on failure
*/
protected static function putCalendarObject(
string $href,
string $username,
string $password,
string $data,
string $etag
): string {
$headers = [
'Content-Type: text/calendar; charset=utf-8',
];
if ($etag !== '') {
$headers[] = 'If-Match: ' . $etag;
}
$response = self::request('PUT', $href, $username, $password, $data, $headers);
if ($response === null) {
return self::$lastRequestError ?: 'CalDAV PUT failed (unknown error)';
}
return '';
}
/**
* 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]) : '';
$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 {
self::$lastRequestError = '';
if (!function_exists('curl_init')) {
self::$lastRequestError = 'CalDAV: curl extension not available';
return null;
}
$ch = curl_init();
if ($ch === false) {
self::$lastRequestError = 'CalDAV: curl_init() failed';
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);
$curlError = curl_error($ch);
curl_close($ch);
if (!is_string($responseBody)) {
self::$lastRequestError = "CalDAV $method failed: curl error: $curlError";
return null;
}
// Accept 2xx and 207 (multistatus) responses
if ($httpCode >= 200 && $httpCode < 300) {
return $responseBody;
}
if ($httpCode === 207) {
return $responseBody;
}
self::$lastRequestError = "CalDAV $method failed: HTTP $httpCode";
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 */
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();
}
}

555
src/CalendarService.php Normal file
View File

@@ -0,0 +1,555 @@
<?php
namespace dokuwiki\plugin\luxtools;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
use Throwable;
/**
* 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 = [];
/** @var array<string,VCalendar|null> In-request cache keyed by file path */
protected static $vcalCache = [];
/**
* 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.
*
* Expands each slot's ICS calendar once for the full month range,
* then buckets events by day — instead of 31 individual expand calls.
*
* @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 = [];
$utc = new DateTimeZone('UTC');
// Expand from 1 day before month start to 1 day after month end
$rangeStart = new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P' . ($daysInMonth + 2) . 'D'));
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
$file = $slot->getFile();
if ($file === '' || !is_file($file) || !is_readable($file)) continue;
$calendar = self::readCalendar($file);
if ($calendar === null) continue;
try {
$expanded = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) continue;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso);
$cacheKey = $slot->getKey() . '|' . $dateIso;
self::$dayCache[$cacheKey] = $events;
if ($events !== []) {
$indicators[$dateIso][] = $slot->getKey();
}
}
} catch (Throwable $e) {
continue;
}
}
return $indicators;
}
/**
* Prepare month data for the calendar widget in one pass.
*
* Uses monthIndicators() to warm the per-slot day cache, then reuses the
* normalized events already cached for each day.
*
* @param CalendarSlot[] $slots
* @param int $year
* @param int $month
* @return array{indicators: array<string,string[]>, events: array<string,CalendarEvent[]>}
*/
public static function monthWidgetData(array $slots, int $year, int $month): array
{
$indicators = self::monthIndicators($slots, $year, $month);
$eventsByDate = [];
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::eventsForDate($slots, $dateIso);
if ($events !== []) {
$eventsByDate[$dateIso] = $events;
}
}
return [
'indicators' => $indicators,
'events' => $eventsByDate,
];
}
/**
* Read and parse an ICS file, caching the parsed VCalendar per file path.
*
* @param string $file
* @return VCalendar|null
*/
protected static function readCalendar(string $file): ?VCalendar
{
if (array_key_exists($file, self::$vcalCache)) {
return self::$vcalCache[$file];
}
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') {
self::$vcalCache[$file] = null;
return null;
}
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) {
self::$vcalCache[$file] = null;
return null;
}
self::$vcalCache[$file] = $component;
return $component;
} catch (Throwable $e) {
self::$vcalCache[$file] = null;
return null;
}
}
/**
* 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
{
$calendar = self::readCalendar($file);
if ($calendar === null) return [];
try {
$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 = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
} catch (Throwable $e) {
return [];
}
}
/**
* Parse all tasks (VEVENT with STATUS) 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
{
$calendar = self::readCalendar($file);
if ($calendar === null) return [];
try {
$component = $calendar;
// 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;
}
}
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;
}
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;
}
/**
* 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 = [];
self::$vcalCache = [];
}
}

225
src/CalendarSlot.php Normal file
View File

@@ -0,0 +1,225 @@
<?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, an optional widget indicator position,
* 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 string[] Allowed widget indicator display positions */
public const INDICATOR_DISPLAYS = ['none', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
/** @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;
/** @var string Widget indicator display position */
protected $display;
/**
* @param string $key
* @param string $file
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $color
* @param string $display
*/
public function __construct(
string $key,
string $file = '',
string $caldavUrl = '',
string $username = '',
string $password = '',
string $color = '',
string $display = 'none'
) {
$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);
$this->display = self::normalizeIndicatorDisplay($display);
}
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;
}
public function getDisplay(): string
{
return $this->display;
}
public function shouldDisplayIndicator(): bool
{
return $this->display !== 'none';
}
/**
* Whether this slot should participate in calendar widget visibility.
*
* @return bool
*/
public function isVisibleInWidget(): bool
{
return $this->shouldDisplayIndicator();
}
/**
* 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'),
(string)$plugin->getConf('calendar_' . $key . '_display')
);
}
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();
});
}
/**
* Keep only slots that should appear in the calendar widget.
*
* @param CalendarSlot[] $slots
* @return CalendarSlot[]
*/
public static function filterWidgetVisible(array $slots): array
{
return array_filter($slots, static function (CalendarSlot $slot): bool {
return $slot->isVisibleInWidget();
});
}
protected static function normalizeIndicatorDisplay(string $display): string
{
$display = strtolower(trim($display));
$display = str_replace(['_', ' '], '-', $display);
if (!in_array($display, self::INDICATOR_DISPLAYS, true)) {
return 'none';
}
return $display;
}
}

View File

@@ -7,18 +7,36 @@ namespace dokuwiki\plugin\luxtools;
*/ */
class ChronologicalCalendarWidget class ChronologicalCalendarWidget
{ {
/** @var int Maximum number of inline events shown per day cell in large mode */
protected const MAX_INLINE_EVENTS = 3;
/** /**
* Render full calendar widget HTML for one month. * Render full calendar widget HTML for one month.
* *
* @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
* @param array<string,string> $slotDisplays slotKey => configured indicator position
* @param array{size?:string,showTimes?:bool,dayEvents?:array<string,CalendarEvent[]>} $options
* @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 = [],
array $slotDisplays = [],
array $options = []
): string {
if (!self::isValidMonth($year, $month)) return ''; if (!self::isValidMonth($year, $month)) return '';
$size = self::normalizeSize((string)($options['size'] ?? 'large'));
$showTimes = (bool)($options['showTimes'] ?? true);
$dayEvents = is_array($options['dayEvents'] ?? null) ? $options['dayEvents'] : [];
$firstDayTs = mktime(0, 0, 0, $month, 1, $year); $firstDayTs = mktime(0, 0, 0, $month, 1, $year);
$daysInMonth = (int)date('t', $firstDayTs); $daysInMonth = (int)date('t', $firstDayTs);
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun) $firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
@@ -50,10 +68,12 @@ class ChronologicalCalendarWidget
$yearUrlTemplate = $dayUrlTemplate; $yearUrlTemplate = $dayUrlTemplate;
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php'; $ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$html = '<div class="luxtools-plugin luxtools-calendar" data-luxtools-calendar="1"' $html = '<div class="luxtools-plugin luxtools-calendar luxtools-calendar-size-' . hsc($size) . '" data-luxtools-calendar="1"'
. ' data-base-ns="' . hsc($baseNs) . '"' . ' data-base-ns="' . hsc($baseNs) . '"'
. ' data-current-year="' . hsc((string)$year) . '"' . ' data-current-year="' . hsc((string)$year) . '"'
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"' . ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
. ' data-luxtools-size="' . hsc($size) . '"'
. ' data-luxtools-show-times="' . ($showTimes ? '1' : '0') . '"'
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"' . ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"' . ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"' . ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
@@ -105,14 +125,39 @@ class ChronologicalCalendarWidget
} else { } else {
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber); $date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
$dayId = ChronoID::dateToDayId($date, $baseNs); $dayId = ChronoID::dateToDayId($date, $baseNs);
$events = $dayEvents[$date] ?? [];
$classes = 'luxtools-calendar-day'; $classes = 'luxtools-calendar-day';
if ($events !== []) {
$classes .= ' luxtools-calendar-day-has-events';
}
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">'; $html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber); if ($size === 'small') {
$dayIndicators = $indicators[$date] ?? [];
if ($dayIndicators !== []) {
$indicatorHtml = '';
foreach ($dayIndicators as $slotKey) {
$display = $slotDisplays[$slotKey] ?? 'none';
if ($display === 'none') continue;
$color = $slotColors[$slotKey] ?? '';
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
$indicatorHtml .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($display) . '"' . $style . '></span>';
}
if ($indicatorHtml !== '') {
$html .= '<div class="luxtools-calendar-indicators">' . $indicatorHtml . '</div>';
}
}
$html .= self::renderDayLink($dayId, (string)$dayNumber);
} else { } else {
$html .= hsc((string)$dayNumber); $html .= '<div class="luxtools-calendar-day-frame">';
$html .= '<div class="luxtools-calendar-day-number">' . self::renderDayLink($dayId, (string)$dayNumber) . '</div>';
$html .= self::renderInlineEvents($events, $slotColors, $showTimes);
$html .= '</div>';
} }
$html .= '</td>'; $html .= '</td>';
} }
@@ -127,6 +172,26 @@ class ChronologicalCalendarWidget
return $html; return $html;
} }
/**
* @param string $size
* @return string
*/
public static function normalizeSize(string $size): string
{
$size = strtolower(trim($size));
return $size === 'small' ? 'small' : 'large';
}
/**
* @param string|null $value
* @return bool
*/
public static function normalizeShowTimes(?string $value): bool
{
if ($value === null) return true;
return trim($value) !== '0';
}
/** /**
* @param int $year * @param int $year
* @param int $month * @param int $month
@@ -138,4 +203,81 @@ class ChronologicalCalendarWidget
if ($month < 1 || $month > 12) return false; if ($month < 1 || $month > 12) return false;
return true; return true;
} }
/**
* @param string|null $dayId
* @param string $label
* @return string
*/
protected static function renderDayLink(?string $dayId, string $label): string
{
if ($dayId !== null && function_exists('html_wikilink')) {
return (string)html_wikilink($dayId, $label);
}
return hsc($label);
}
/**
* @param CalendarEvent[] $events
* @param array<string,string> $slotColors
* @param bool $showTimes
* @return string
*/
protected static function renderInlineEvents(array $events, array $slotColors, bool $showTimes): string
{
if ($events === []) {
return '<div class="luxtools-calendar-day-events"></div>';
}
$html = '<div class="luxtools-calendar-day-events"><ul class="luxtools-calendar-event-list">';
$visibleCount = min(count($events), self::MAX_INLINE_EVENTS);
for ($index = 0; $index < $visibleCount; $index++) {
$event = $events[$index];
$color = $slotColors[$event->slotKey] ?? '';
$style = $color !== '' ? ' style="--luxtools-slot-color:' . hsc($color) . '"' : '';
$dataAttrs = self::renderEventDataAttributes($event);
$html .= '<li class="luxtools-calendar-event"' . $style . $dataAttrs . '>';
if ($showTimes && !$event->allDay && $event->time !== '') {
$html .= '<span class="luxtools-calendar-event-time luxtools-event-time" data-luxtools-start="'
. hsc($event->startIso) . '">' . hsc($event->time) . '</span>';
}
$html .= '<span class="luxtools-calendar-event-title">' . hsc($event->summary) . '</span>';
$html .= '</li>';
}
$remaining = count($events) - $visibleCount;
if ($remaining > 0) {
$html .= '<li class="luxtools-calendar-event luxtools-calendar-event-more">+' . hsc((string)$remaining) . ' more</li>';
}
$html .= '</ul></div>';
return $html;
}
/**
* @param CalendarEvent $event
* @return string
*/
protected static function renderEventDataAttributes(CalendarEvent $event): string
{
$attrs = ' data-luxtools-event="1"';
$attrs .= ' data-event-summary="' . hsc($event->summary) . '"';
$attrs .= ' data-event-start="' . hsc($event->startIso) . '"';
if ($event->endIso !== '') {
$attrs .= ' data-event-end="' . hsc($event->endIso) . '"';
}
if ($event->location !== '') {
$attrs .= ' data-event-location="' . hsc($event->location) . '"';
}
if ($event->description !== '') {
$attrs .= ' data-event-description="' . hsc($event->description) . '"';
}
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
return $attrs;
}
} }

292
src/IcsWriter.php Normal file
View File

@@ -0,0 +1,292 @@
<?php
namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
use Throwable;
/**
* Write-back support for local ICS files.
*
* Handles updating event status (completion, reopening) in local
* ICS files while preserving other properties.
*/
class IcsWriter
{
/**
* Update the STATUS of an event occurrence in a local ICS file.
*
* Sets STATUS to the given value (TODO or COMPLETED).
*
* 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;
}
}
// 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;
}
return false;
}
/**
* Check if a component matches the given UID and recurrence criteria.
*
* @param VEvent $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;
}
/**
* 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;
}
}
/**
* 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;
}
}

372
style.css
View File

@@ -599,7 +599,8 @@ div.luxtools-calendar td.luxtools-calendar-day-today {
background-color: @ini_highlight; background-color: @ini_highlight;
} }
div.luxtools-calendar td.luxtools-calendar-day a { div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -612,27 +613,33 @@ div.luxtools-calendar td.luxtools-calendar-day a {
padding: 0.1em 0; padding: 0.1em 0;
} }
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:link, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:link,
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:visited { div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:visited {
color: @ini_missing; color: @ini_missing;
border-bottom: 0; border-bottom: 0;
} }
div.luxtools-calendar td.luxtools-calendar-day a:hover, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:hover,
div.luxtools-calendar td.luxtools-calendar-day a:focus, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:focus,
div.luxtools-calendar td.luxtools-calendar-day a:active, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:active,
div.luxtools-calendar td.luxtools-calendar-day a:visited { div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:visited,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited {
text-decoration: none; text-decoration: none;
border-bottom: 0; border-bottom: 0;
box-shadow: none; box-shadow: none;
} }
div.luxtools-calendar td.luxtools-calendar-day span.curid > a, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:visited, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:hover, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:focus, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:active { div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active {
font-weight: bold; font-weight: bold;
text-decoration: underline; text-decoration: underline;
border-bottom: 0; border-bottom: 0;
@@ -646,3 +653,344 @@ 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;
}
div.luxtools-calendar.luxtools-calendar-size-large table.luxtools-calendar-table td {
text-align: left;
vertical-align: top;
}
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day {
height: 8.25em;
}
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty {
height: 8.25em;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame {
min-height: 8.25em;
padding: 0.35em 0.4em 0.4em 0.4em;
box-sizing: border-box;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number {
text-align: right;
margin-bottom: 0.25em;
line-height: 1.1;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > span.curid > a,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a {
display: inline;
min-height: 0;
padding: 0;
background: transparent;
text-decoration: none;
border-bottom: 0;
box-shadow: none;
font-weight: bold;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:visited {
color: @ini_missing;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-events {
overflow: hidden;
}
div.luxtools-calendar.luxtools-calendar-size-large ul.luxtools-calendar-event-list {
list-style: none;
margin: 0;
padding: 0;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event {
display: flex;
align-items: baseline;
gap: 0.35em;
margin: 0 0 0.2em 0;
padding: 0.1em 0.2em 0.1em 0.35em;
border-left: 3px solid var(--luxtools-slot-color, @ini_border);
background-color: @ini_background_alt;
overflow: hidden;
cursor: pointer;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event:hover {
background-color: @ini_highlight;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-time {
flex: 0 0 auto;
font-weight: bold;
white-space: nowrap;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-title {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event-more {
border-left-color: @ini_border;
justify-content: flex-end;
font-style: italic;
}
@media (max-width: 800px) {
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day,
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame {
height: 7em;
min-height: 7em;
}
}
.luxtools-calendar-indicators {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.luxtools-calendar-indicator {
position: absolute;
width: 10px;
height: 10px;
}
.luxtools-indicator-top-left {
top: 0;
left: 0;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
.luxtools-indicator-top-right {
top: 0;
right: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.luxtools-indicator-bottom-right {
bottom: 0;
right: 0;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.luxtools-indicator-bottom-left {
bottom: 0;
left: 0;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}
/* ============================================================
* 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');
@@ -62,6 +64,8 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
'year' => $resolved['year'], 'year' => $resolved['year'],
'month' => $resolved['month'], 'month' => $resolved['month'],
'base' => $baseNs, 'base' => $baseNs,
'size' => ChronologicalCalendarWidget::normalizeSize((string)($params['size'] ?? 'large')),
'show_times' => ChronologicalCalendarWidget::normalizeShowTimes($params['show_times'] ?? null),
]; ];
} }
@@ -84,8 +88,36 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$year = (int)$data['year']; $year = (int)$data['year'];
$month = (int)$data['month']; $month = (int)$data['month'];
$baseNs = (string)$data['base']; $baseNs = (string)$data['base'];
$size = ChronologicalCalendarWidget::normalizeSize((string)($data['size'] ?? 'large'));
$showTimes = (bool)($data['show_times'] ?? true);
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs); $slots = CalendarSlot::loadEnabled($this);
$widgetSlots = CalendarSlot::filterWidgetVisible($slots);
$indicators = [];
$dayEvents = [];
if ($size === 'large') {
$widgetData = CalendarService::monthWidgetData($widgetSlots, $year, $month);
$indicators = $widgetData['indicators'];
$dayEvents = $widgetData['events'];
} else {
$indicators = CalendarService::monthIndicators($widgetSlots, $year, $month);
}
$slotColors = [];
$slotDisplays = [];
foreach ($widgetSlots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
}
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [
'size' => $size,
'showTimes' => $showTimes,
'dayEvents' => $dayEvents,
]);
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;
}
}