Compare commits
5 Commits
87f6839b0d
...
97d9647ea2
| Author | SHA1 | Date | |
|---|---|---|---|
| 97d9647ea2 | |||
| 6162ff595f | |||
| 94215fdd65 | |||
| a4815fc672 | |||
| 2d5e9541c2 |
118
README.md
118
README.md
@@ -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:
|
||||||
|
|||||||
414
action.php
414
action.php
@@ -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 '';
|
||||||
|
|
||||||
$title = (string)$this->getLang('chronological_events_title');
|
$html = '';
|
||||||
if ($title === '') $title = 'Events';
|
|
||||||
|
|
||||||
$items = '';
|
// Render general events
|
||||||
foreach ($events as $entry) {
|
if (isset($grouped['general'])) {
|
||||||
$summary = trim((string)($entry['summary'] ?? ''));
|
$title = (string)$this->getLang('chronological_events_title');
|
||||||
if ($summary === '') $summary = '(ohne Titel)';
|
if ($title === '') $title = 'Events';
|
||||||
|
$html .= $this->renderEventSection($grouped['general'], $title, 'general');
|
||||||
|
}
|
||||||
|
|
||||||
$time = trim((string)($entry['time'] ?? ''));
|
// Render maintenance tasks
|
||||||
$startIso = trim((string)($entry['startIso'] ?? ''));
|
if (isset($grouped['maintenance'])) {
|
||||||
$isAllDay = (bool)($entry['allDay'] ?? false);
|
$title = (string)$this->getLang('chronological_maintenance_title');
|
||||||
|
if ($title === '') $title = 'Tasks';
|
||||||
|
$html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso);
|
||||||
|
}
|
||||||
|
|
||||||
if ($isAllDay || $time === '') {
|
// Render slot3/slot4 if present
|
||||||
$items .= '<li>' . hsc($summary) . '</li>';
|
foreach (['slot3', 'slot4'] as $slotKey) {
|
||||||
} else {
|
if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
|
||||||
$timeHtml = '<span class="luxtools-event-time"';
|
$label = $slots[$slotKey]->getLabel();
|
||||||
if ($startIso !== '') {
|
$html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey);
|
||||||
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
|
|
||||||
}
|
|
||||||
$timeHtml .= '>' . hsc($time) . '</span>';
|
|
||||||
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($items === '') return '';
|
return $html;
|
||||||
$html = '<ul>' . $items . '</ul>';
|
}
|
||||||
|
|
||||||
return '<div class="luxtools-plugin luxtools-chronological-events">'
|
/**
|
||||||
|
* Render a section of events for a given slot.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent[] $events
|
||||||
|
* @param string $title
|
||||||
|
* @param string $slotKey
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderEventSection(array $events, string $title, string $slotKey): string
|
||||||
|
{
|
||||||
|
$items = '';
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$items .= $this->renderEventListItem($event);
|
||||||
|
}
|
||||||
|
if ($items === '') return '';
|
||||||
|
|
||||||
|
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-slot-' . hsc($slotKey) . '">'
|
||||||
. '<h2>' . hsc($title) . '</h2>'
|
. '<h2>' . hsc($title) . '</h2>'
|
||||||
. $html
|
. '<ul>' . $items . '</ul>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a maintenance task section with completion buttons.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent[] $events
|
||||||
|
* @param string $title
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderMaintenanceSection(array $events, string $title, string $dateIso): string
|
||||||
|
{
|
||||||
|
$items = '';
|
||||||
|
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$items .= $this->renderMaintenanceListItem($event, $ajaxUrl);
|
||||||
|
}
|
||||||
|
if ($items === '') return '';
|
||||||
|
|
||||||
|
$secToken = function_exists('getSecurityToken') ? getSecurityToken() : '';
|
||||||
|
|
||||||
|
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-chronological-maintenance"'
|
||||||
|
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
|
||||||
|
. ' data-luxtools-sectok="' . hsc($secToken) . '">'
|
||||||
|
. '<h2>' . hsc($title) . '</h2>'
|
||||||
|
. '<ul>' . $items . '</ul>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single event as a list item with popup data attributes.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent $event
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderEventListItem(CalendarEvent $event): string
|
||||||
|
{
|
||||||
|
$summaryHtml = hsc($event->summary);
|
||||||
|
|
||||||
|
// Build event detail data attributes for popup
|
||||||
|
$dataAttrs = ' data-luxtools-event="1"';
|
||||||
|
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
||||||
|
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
||||||
|
if ($event->endIso !== '') {
|
||||||
|
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
||||||
|
}
|
||||||
|
if ($event->location !== '') {
|
||||||
|
$dataAttrs .= ' data-event-location="' . hsc($event->location) . '"';
|
||||||
|
}
|
||||||
|
if ($event->description !== '') {
|
||||||
|
$dataAttrs .= ' data-event-description="' . hsc($event->description) . '"';
|
||||||
|
}
|
||||||
|
$dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||||
|
$dataAttrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
|
||||||
|
|
||||||
|
if ($event->allDay || $event->time === '') {
|
||||||
|
return '<li' . $dataAttrs . '><span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
|
||||||
|
. hsc($event->time) . '</span>';
|
||||||
|
return '<li' . $dataAttrs . '>' . $timeHtml . ' - <span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a maintenance task as a list item with completion button.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent $event
|
||||||
|
* @param string $ajaxUrl
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderMaintenanceListItem(CalendarEvent $event, string $ajaxUrl): string
|
||||||
|
{
|
||||||
|
$isCompleted = $event->isCompleted();
|
||||||
|
$classes = 'luxtools-maintenance-task';
|
||||||
|
if ($isCompleted) $classes .= ' luxtools-task-completed';
|
||||||
|
|
||||||
|
$summaryHtml = hsc($event->summary);
|
||||||
|
|
||||||
|
// Data attributes for popup and completion
|
||||||
|
$dataAttrs = ' data-luxtools-event="1"';
|
||||||
|
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
||||||
|
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
||||||
|
if ($event->endIso !== '') {
|
||||||
|
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
||||||
|
}
|
||||||
|
if ($event->location !== '') {
|
||||||
|
$dataAttrs .= ' data-event-location="' . hsc($event->location) . '"';
|
||||||
|
}
|
||||||
|
if ($event->description !== '') {
|
||||||
|
$dataAttrs .= ' data-event-description="' . hsc($event->description) . '"';
|
||||||
|
}
|
||||||
|
$dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||||
|
$dataAttrs .= ' data-event-slot="maintenance"';
|
||||||
|
$dataAttrs .= ' data-task-uid="' . hsc($event->uid) . '"';
|
||||||
|
$dataAttrs .= ' data-task-date="' . hsc($event->dateIso) . '"';
|
||||||
|
$dataAttrs .= ' data-task-recurrence="' . hsc($event->recurrenceId) . '"';
|
||||||
|
$dataAttrs .= ' data-task-status="' . hsc($event->status) . '"';
|
||||||
|
|
||||||
|
$buttonLabel = $isCompleted
|
||||||
|
? (string)$this->getLang('maintenance_task_reopen')
|
||||||
|
: (string)$this->getLang('maintenance_task_complete');
|
||||||
|
if ($buttonLabel === '') $buttonLabel = $isCompleted ? 'Reopen' : 'Complete';
|
||||||
|
$buttonAction = $isCompleted ? 'reopen' : 'complete';
|
||||||
|
|
||||||
|
$buttonHtml = '<button type="button" class="luxtools-task-action" data-action="' . hsc($buttonAction) . '">'
|
||||||
|
. hsc($buttonLabel) . '</button>';
|
||||||
|
|
||||||
|
$timeHtml = '';
|
||||||
|
if (!$event->allDay && $event->time !== '') {
|
||||||
|
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
|
||||||
|
. hsc($event->time) . '</span> - ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<li class="' . hsc($classes) . '"' . $dataAttrs . '>'
|
||||||
|
. $timeHtml
|
||||||
|
. '<span class="luxtools-event-summary">' . $summaryHtml . '</span> '
|
||||||
|
. $buttonHtml
|
||||||
|
. '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle AJAX requests for marking maintenance tasks complete/reopen.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handleMaintenanceTaskAction(Event $event, $param)
|
||||||
|
{
|
||||||
|
if ($event->data !== 'luxtools_maintenance_task') return;
|
||||||
|
|
||||||
|
$event->preventDefault();
|
||||||
|
$event->stopPropagation();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$this->sendNoStoreHeaders();
|
||||||
|
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
// Verify security token
|
||||||
|
if (!checkSecurityToken()) {
|
||||||
|
http_status(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Security token mismatch']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $INPUT->str('action'); // 'complete' or 'reopen'
|
||||||
|
$uid = $INPUT->str('uid');
|
||||||
|
$dateIso = $INPUT->str('date');
|
||||||
|
$recurrence = $INPUT->str('recurrence');
|
||||||
|
|
||||||
|
if (!in_array($action, ['complete', 'reopen'], true)) {
|
||||||
|
http_status(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Invalid action']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($uid === '' || !ChronoID::isIsoDate($dateIso)) {
|
||||||
|
http_status(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Missing uid or date']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slots = CalendarSlot::loadAll($this);
|
||||||
|
$maintenanceSlot = $slots['maintenance'] ?? null;
|
||||||
|
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
|
||||||
|
http_status(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Maintenance calendar not configured']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = ($action === 'complete') ? 'COMPLETED' : 'TODO';
|
||||||
|
|
||||||
|
// Update local ICS file
|
||||||
|
$localOk = false;
|
||||||
|
$file = $maintenanceSlot->getFile();
|
||||||
|
if ($file !== '' && is_file($file)) {
|
||||||
|
$localOk = IcsWriter::updateEventStatus($file, $uid, $recurrence, $newStatus, $dateIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$localOk) {
|
||||||
|
http_status(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $this->getLang('maintenance_complete_error')]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear caches so next render picks up changes
|
||||||
|
CalendarService::clearCache();
|
||||||
|
|
||||||
|
// Remote CalDAV write-back if configured
|
||||||
|
$remoteOk = true;
|
||||||
|
$remoteError = '';
|
||||||
|
if ($maintenanceSlot->hasRemoteSource()) {
|
||||||
|
try {
|
||||||
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
admin/main.php
127
admin/main.php
@@ -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,11 +272,88 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
|
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
|
||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
|
|
||||||
// calendar_ics_files
|
// Calendar slot settings
|
||||||
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
|
$slotLabels = [
|
||||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
|
'general' => 'General',
|
||||||
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
|
'maintenance' => 'Maintenance',
|
||||||
echo '</label><br />';
|
'slot3' => 'Slot 3',
|
||||||
|
'slot4' => 'Slot 4',
|
||||||
|
];
|
||||||
|
$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 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§ok=" + 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> ';
|
||||||
|
|||||||
@@ -23,4 +23,4 @@
|
|||||||
# Conduct and user interactions
|
# Conduct and user interactions
|
||||||
- The user is a professional software developer, but unfamiliar with Dokuwiki internals, PHP and JavaScript best practices
|
- The user is a professional software developer, but unfamiliar with Dokuwiki internals, PHP and JavaScript best practices
|
||||||
- When the user gives specific instructions regarding implementation details, check wether those details fit PHP and Dokuwiki best practices.
|
- When the user gives specific instructions regarding implementation details, check wether those details fit PHP and Dokuwiki best practices.
|
||||||
- If the user instructions conflict with best practices, point out the conflict and suggest alternatives
|
- If the user instructions conflict with best practices, point out the conflict and suggest alternatives
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
310
js/event-popup.js
Normal 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">×</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 += ' – ' + formatDate(end);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += '<strong>Time:</strong> ' + formatDateTime(start);
|
||||||
|
if (end && !isSameMoment(start, end)) {
|
||||||
|
html += ' – ' + 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)
|
||||||
|
+ '§ok=' + 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;
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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
475
src/CalDavClient.php
Normal 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
86
src/CalendarEvent.php
Normal 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
555
src/CalendarService.php
Normal 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
225
src/CalendarSlot.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
292
src/IcsWriter.php
Normal 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
372
style.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
129
syntax/maintenance.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user