Calendar Sync V1
This commit is contained in:
118
README.md
118
README.md
@@ -163,13 +163,26 @@ Key settings:
|
||||
`YYYY-MM-DD` are listed automatically.
|
||||
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
|
||||
|
||||
- **calendar_ics_files**
|
||||
Local calendar `.ics` files (one absolute file path per line).
|
||||
Events are parsed by `sabre/vobject` and shown on matching chronological day pages.
|
||||
Recurrence and exclusions from the ICS are respected. For timed entries, the
|
||||
page stores the original timestamp and renders the visible time in the
|
||||
browser's local timezone.
|
||||
Multi-day events appear on each overlapping day.
|
||||
- **calendar_ics_files** (REMOVED — replaced by per-slot calendar configuration)
|
||||
|
||||
- **Calendar Slots** (configured via Admin -> luxtools)
|
||||
The plugin supports 4 calendar slots: `general`, `maintenance`, `slot3`, `slot4`.
|
||||
Each slot has its own settings:
|
||||
|
||||
- **File**: Local `.ics` file path
|
||||
- **CalDAV URL**: Remote CalDAV collection URL (optional)
|
||||
- **Username**: CalDAV authentication username
|
||||
- **Password**: CalDAV authentication password
|
||||
- **Color**: CSS color for calendar widget indicators
|
||||
|
||||
A slot is enabled if it has a local file path or a CalDAV URL configured.
|
||||
The old `calendar_ics_files` setting has been replaced by the `general` slot's file path.
|
||||
|
||||
Default colors:
|
||||
- general: `#4a90d9` (blue)
|
||||
- maintenance: `#e67e22` (orange)
|
||||
- slot3: `#27ae60` (green)
|
||||
- slot4: `#8e44ad` (purple)
|
||||
|
||||
- **pagelink_search_depth**
|
||||
Maximum directory depth for `.pagelink` discovery under each configured root.
|
||||
@@ -287,6 +300,10 @@ Notes:
|
||||
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
|
||||
- Calendar output is marked as non-cacheable to keep missing/existing link styling and
|
||||
current-day highlighting up to date.
|
||||
- Each day cell shows colored corner indicators for calendar slots that have events on that day.
|
||||
Indicator positions (clockwise): general = top-left, maintenance = top-right,
|
||||
slot3 = bottom-right, slot4 = bottom-left.
|
||||
Indicator colors are taken from the slot's configured color.
|
||||
|
||||
### 0.4) Virtual chronological day pages
|
||||
|
||||
@@ -297,7 +314,8 @@ default "page does not exist" output.
|
||||
The virtual page includes:
|
||||
|
||||
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
|
||||
- matching local calendar events from configured `.ics` files (when available)
|
||||
- matching calendar events from all enabled calendar slots (grouped by slot)
|
||||
- maintenance tasks with completion buttons (from the maintenance slot)
|
||||
- matching day photos (via existing `{{images>...}}` rendering) when available
|
||||
|
||||
The page is only created once you edit and save actual content.
|
||||
@@ -314,6 +332,90 @@ luxtools provides an admin-only **Invalidate Cache** action in the page tools me
|
||||
permission errors).
|
||||
- Also useful when actively adding external photos to the current day page.
|
||||
|
||||
### 0.6) Multi-calendar slot system
|
||||
|
||||
The plugin supports 4 calendar slots, each with independent configuration for
|
||||
a local `.ics` file, CalDAV URL, authentication, and display color.
|
||||
|
||||
- **general**: The default event calendar. Events appear on day pages like before.
|
||||
- **maintenance**: A task-oriented calendar. Events are treated as tasks with
|
||||
completion tracking. Tasks can be marked complete/reopened via buttons on day pages.
|
||||
- **slot3**, **slot4**: Reserved for future use. Events from these slots appear
|
||||
on day pages with the slot's label as the section heading.
|
||||
|
||||
Calendar data is always read from local `.ics` files for rendering. If a remote
|
||||
CalDAV source is configured, use the sync feature to populate the local file.
|
||||
|
||||
### 0.7) Maintenance task completion
|
||||
|
||||
Maintenance tasks shown on day pages include a "Complete" button. Clicking it:
|
||||
|
||||
1. Updates the event's `STATUS` property in the local `.ics` file.
|
||||
2. If the maintenance slot has a CalDAV URL configured, also updates the remote
|
||||
calendar object.
|
||||
3. Shows visual feedback and reports any remote write failures.
|
||||
|
||||
Completed tasks can be reopened with a "Reopen" button.
|
||||
|
||||
Write-back rules:
|
||||
- `VEVENT` components: `STATUS:TODO` for open, `STATUS:COMPLETED` for completed.
|
||||
- `VTODO` components: Uses native completion semantics (`STATUS:COMPLETED`,
|
||||
`COMPLETED` timestamp, `PERCENT-COMPLETE:100`).
|
||||
- Recurring events: Completion writes an occurrence override/exception to preserve
|
||||
per-occurrence state rather than modifying the master event.
|
||||
|
||||
### 0.8) Event popup
|
||||
|
||||
Clicking any event on a day page opens a popup overlay showing:
|
||||
- Title
|
||||
- Date/time (formatted in the browser's locale)
|
||||
- Location (if available)
|
||||
- Description (if available)
|
||||
- Calendar slot name
|
||||
|
||||
Close the popup by clicking outside it or pressing Escape.
|
||||
|
||||
### 0.9) Maintenance task list syntax
|
||||
|
||||
Embed a list of open maintenance tasks anywhere on a wiki page:
|
||||
|
||||
```
|
||||
{{maintenance_tasks>}}
|
||||
```
|
||||
|
||||
This renders all non-completed maintenance tasks due today or earlier, sorted
|
||||
with overdue tasks first (then by date, time, and title).
|
||||
|
||||
Each task shows its date, optional time, summary, and a "Complete" button.
|
||||
|
||||
### 0.10) CalDAV sync
|
||||
|
||||
If a slot has a CalDAV URL configured, the admin panel provides a sync button.
|
||||
Triggering sync downloads all calendar objects from the remote CalDAV collection
|
||||
and merges them into the slot's local `.ics` file.
|
||||
|
||||
Sync is admin-only and does not run automatically. For scheduled sync, set up
|
||||
a cron job that triggers the sync via the DokuWiki AJAX endpoint.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Recurring event completion write-back**: For recurring events, completing a
|
||||
single occurrence writes an override/exception component to the `.ics` file.
|
||||
This works well for simple `RRULE` patterns. Some CalDAV servers may handle
|
||||
the override differently. If the override is rejected by the remote server,
|
||||
the local file will still have the correct state, but remote sync may
|
||||
overwrite it on next sync.
|
||||
|
||||
- **Sync direction**: Sync is currently one-directional (remote → local). Local
|
||||
changes made via task completion are written back to the remote individually,
|
||||
but a full remote-to-local sync may overwrite local changes if the remote
|
||||
still has stale data. The completion write-back updates the remote immediately
|
||||
to mitigate this.
|
||||
|
||||
- **VTODO recurrence**: sabre/vobject's recurrence expansion has limited support
|
||||
for `VTODO` components. Recurring `VTODO` items may not expand as expected.
|
||||
Non-recurring `VTODO` items work correctly.
|
||||
|
||||
### 1) List files by glob pattern
|
||||
|
||||
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
|
||||
|
||||
397
action.php
397
action.php
@@ -4,11 +4,15 @@ use dokuwiki\Extension\ActionPlugin;
|
||||
use dokuwiki\Extension\Event;
|
||||
use dokuwiki\Extension\EventHandler;
|
||||
use dokuwiki\plugin\luxtools\CacheInvalidation;
|
||||
use dokuwiki\plugin\luxtools\CalDavClient;
|
||||
use dokuwiki\plugin\luxtools\CalendarEvent;
|
||||
use dokuwiki\plugin\luxtools\CalendarService;
|
||||
use dokuwiki\plugin\luxtools\CalendarSlot;
|
||||
use dokuwiki\plugin\luxtools\ChronoID;
|
||||
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
||||
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
|
||||
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
|
||||
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
|
||||
use dokuwiki\plugin\luxtools\IcsWriter;
|
||||
use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache;
|
||||
require_once(__DIR__ . '/autoload.php');
|
||||
|
||||
@@ -65,6 +69,18 @@ class action_plugin_luxtools extends ActionPlugin
|
||||
$this,
|
||||
"handleCalendarWidgetAjax",
|
||||
);
|
||||
$controller->register_hook(
|
||||
"AJAX_CALL_UNKNOWN",
|
||||
"BEFORE",
|
||||
$this,
|
||||
"handleMaintenanceTaskAction",
|
||||
);
|
||||
$controller->register_hook(
|
||||
"AJAX_CALL_UNKNOWN",
|
||||
"BEFORE",
|
||||
$this,
|
||||
"handleCalendarSyncAction",
|
||||
);
|
||||
$controller->register_hook(
|
||||
"ACTION_ACT_PREPROCESS",
|
||||
"BEFORE",
|
||||
@@ -105,6 +121,7 @@ class action_plugin_luxtools extends ActionPlugin
|
||||
"page-link.js",
|
||||
"linkfavicon.js",
|
||||
"calendar-widget.js",
|
||||
"event-popup.js",
|
||||
"main.js",
|
||||
];
|
||||
|
||||
@@ -147,7 +164,18 @@ class action_plugin_luxtools extends ActionPlugin
|
||||
|
||||
$this->sendNoStoreHeaders();
|
||||
|
||||
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs);
|
||||
// Load slot indicators and colors for the calendar widget
|
||||
$slots = CalendarSlot::loadEnabled($this);
|
||||
$indicators = CalendarService::monthIndicators($slots, $year, $month);
|
||||
$slotColors = [];
|
||||
foreach ($slots as $slot) {
|
||||
$color = $slot->getColor();
|
||||
if ($color !== '') {
|
||||
$slotColors[$slot->getKey()] = $color;
|
||||
}
|
||||
}
|
||||
|
||||
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
|
||||
if ($html === '') {
|
||||
http_status(500);
|
||||
echo 'Calendar rendering failed';
|
||||
@@ -458,50 +486,348 @@ class action_plugin_luxtools extends ActionPlugin
|
||||
/**
|
||||
* Render local calendar events section for a given date.
|
||||
*
|
||||
* Uses the slot-aware CalendarService to render events from all enabled slots.
|
||||
*
|
||||
* @param string $dateIso
|
||||
* @return string
|
||||
*/
|
||||
protected function renderChronologicalEventsHtml(string $dateIso): string
|
||||
{
|
||||
$icsConfig = (string)$this->getConf('calendar_ics_files');
|
||||
if (trim($icsConfig) === '') return '';
|
||||
$slots = CalendarSlot::loadEnabled($this);
|
||||
if ($slots === []) return '';
|
||||
|
||||
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
|
||||
if ($events === []) return '';
|
||||
$grouped = CalendarService::eventsForDateGrouped($slots, $dateIso);
|
||||
if ($grouped === []) return '';
|
||||
|
||||
$html = '';
|
||||
|
||||
// Render general events
|
||||
if (isset($grouped['general'])) {
|
||||
$title = (string)$this->getLang('chronological_events_title');
|
||||
if ($title === '') $title = 'Events';
|
||||
$html .= $this->renderEventSection($grouped['general'], $title, 'general');
|
||||
}
|
||||
|
||||
// Render maintenance tasks
|
||||
if (isset($grouped['maintenance'])) {
|
||||
$title = (string)$this->getLang('chronological_maintenance_title');
|
||||
if ($title === '') $title = 'Tasks';
|
||||
$html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso);
|
||||
}
|
||||
|
||||
// Render slot3/slot4 if present
|
||||
foreach (['slot3', 'slot4'] as $slotKey) {
|
||||
if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
|
||||
$label = $slots[$slotKey]->getLabel();
|
||||
$html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a section of events for a given slot.
|
||||
*
|
||||
* @param CalendarEvent[] $events
|
||||
* @param string $title
|
||||
* @param string $slotKey
|
||||
* @return string
|
||||
*/
|
||||
protected function renderEventSection(array $events, string $title, string $slotKey): string
|
||||
{
|
||||
$items = '';
|
||||
foreach ($events as $entry) {
|
||||
$summary = trim((string)($entry['summary'] ?? ''));
|
||||
if ($summary === '') $summary = '(ohne Titel)';
|
||||
|
||||
$time = trim((string)($entry['time'] ?? ''));
|
||||
$startIso = trim((string)($entry['startIso'] ?? ''));
|
||||
$isAllDay = (bool)($entry['allDay'] ?? false);
|
||||
|
||||
if ($isAllDay || $time === '') {
|
||||
$items .= '<li>' . hsc($summary) . '</li>';
|
||||
} else {
|
||||
$timeHtml = '<span class="luxtools-event-time"';
|
||||
if ($startIso !== '') {
|
||||
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
|
||||
foreach ($events as $event) {
|
||||
$items .= $this->renderEventListItem($event);
|
||||
}
|
||||
$timeHtml .= '>' . hsc($time) . '</span>';
|
||||
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($items === '') return '';
|
||||
$html = '<ul>' . $items . '</ul>';
|
||||
|
||||
return '<div class="luxtools-plugin luxtools-chronological-events">'
|
||||
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-slot-' . hsc($slotKey) . '">'
|
||||
. '<h2>' . hsc($title) . '</h2>'
|
||||
. $html
|
||||
. '<ul>' . $items . '</ul>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a maintenance task section with completion buttons.
|
||||
*
|
||||
* @param CalendarEvent[] $events
|
||||
* @param string $title
|
||||
* @param string $dateIso
|
||||
* @return string
|
||||
*/
|
||||
protected function renderMaintenanceSection(array $events, string $title, string $dateIso): string
|
||||
{
|
||||
$items = '';
|
||||
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
|
||||
|
||||
foreach ($events as $event) {
|
||||
$items .= $this->renderMaintenanceListItem($event, $ajaxUrl);
|
||||
}
|
||||
if ($items === '') return '';
|
||||
|
||||
$secToken = function_exists('getSecurityToken') ? getSecurityToken() : '';
|
||||
|
||||
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-chronological-maintenance"'
|
||||
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
|
||||
. ' data-luxtools-sectok="' . hsc($secToken) . '">'
|
||||
. '<h2>' . hsc($title) . '</h2>'
|
||||
. '<ul>' . $items . '</ul>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single event as a list item with popup data attributes.
|
||||
*
|
||||
* @param CalendarEvent $event
|
||||
* @return string
|
||||
*/
|
||||
protected function renderEventListItem(CalendarEvent $event): string
|
||||
{
|
||||
$summaryHtml = hsc($event->summary);
|
||||
|
||||
// Build event detail data attributes for popup
|
||||
$dataAttrs = ' data-luxtools-event="1"';
|
||||
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
||||
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
||||
if ($event->endIso !== '') {
|
||||
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
||||
}
|
||||
if ($event->location !== '') {
|
||||
$dataAttrs .= ' data-event-location="' . hsc($event->location) . '"';
|
||||
}
|
||||
if ($event->description !== '') {
|
||||
$dataAttrs .= ' data-event-description="' . hsc($event->description) . '"';
|
||||
}
|
||||
$dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||
$dataAttrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
|
||||
|
||||
if ($event->allDay || $event->time === '') {
|
||||
return '<li' . $dataAttrs . '><span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
|
||||
}
|
||||
|
||||
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
|
||||
. hsc($event->time) . '</span>';
|
||||
return '<li' . $dataAttrs . '>' . $timeHtml . ' - <span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a maintenance task as a list item with completion button.
|
||||
*
|
||||
* @param CalendarEvent $event
|
||||
* @param string $ajaxUrl
|
||||
* @return string
|
||||
*/
|
||||
protected function renderMaintenanceListItem(CalendarEvent $event, string $ajaxUrl): string
|
||||
{
|
||||
$isCompleted = $event->isCompleted();
|
||||
$classes = 'luxtools-maintenance-task';
|
||||
if ($isCompleted) $classes .= ' luxtools-task-completed';
|
||||
|
||||
$summaryHtml = hsc($event->summary);
|
||||
|
||||
// Data attributes for popup and completion
|
||||
$dataAttrs = ' data-luxtools-event="1"';
|
||||
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
||||
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
||||
if ($event->endIso !== '') {
|
||||
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
||||
}
|
||||
if ($event->location !== '') {
|
||||
$dataAttrs .= ' data-event-location="' . hsc($event->location) . '"';
|
||||
}
|
||||
if ($event->description !== '') {
|
||||
$dataAttrs .= ' data-event-description="' . hsc($event->description) . '"';
|
||||
}
|
||||
$dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||
$dataAttrs .= ' data-event-slot="maintenance"';
|
||||
$dataAttrs .= ' data-task-uid="' . hsc($event->uid) . '"';
|
||||
$dataAttrs .= ' data-task-date="' . hsc($event->dateIso) . '"';
|
||||
$dataAttrs .= ' data-task-recurrence="' . hsc($event->recurrenceId) . '"';
|
||||
$dataAttrs .= ' data-task-status="' . hsc($event->status) . '"';
|
||||
|
||||
$buttonLabel = $isCompleted
|
||||
? (string)$this->getLang('maintenance_task_reopen')
|
||||
: (string)$this->getLang('maintenance_task_complete');
|
||||
if ($buttonLabel === '') $buttonLabel = $isCompleted ? 'Reopen' : 'Complete';
|
||||
$buttonAction = $isCompleted ? 'reopen' : 'complete';
|
||||
|
||||
$buttonHtml = '<button type="button" class="luxtools-task-action" data-action="' . hsc($buttonAction) . '">'
|
||||
. hsc($buttonLabel) . '</button>';
|
||||
|
||||
$timeHtml = '';
|
||||
if (!$event->allDay && $event->time !== '') {
|
||||
$timeHtml = '<span class="luxtools-event-time" data-luxtools-start="' . hsc($event->startIso) . '">'
|
||||
. hsc($event->time) . '</span> - ';
|
||||
}
|
||||
|
||||
return '<li class="' . hsc($classes) . '"' . $dataAttrs . '>'
|
||||
. $timeHtml
|
||||
. '<span class="luxtools-event-summary">' . $summaryHtml . '</span> '
|
||||
. $buttonHtml
|
||||
. '</li>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX requests for marking maintenance tasks complete/reopen.
|
||||
*
|
||||
* @param Event $event
|
||||
* @param mixed $param
|
||||
* @return void
|
||||
*/
|
||||
public function handleMaintenanceTaskAction(Event $event, $param)
|
||||
{
|
||||
if ($event->data !== 'luxtools_maintenance_task') return;
|
||||
|
||||
$event->preventDefault();
|
||||
$event->stopPropagation();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$this->sendNoStoreHeaders();
|
||||
|
||||
global $INPUT;
|
||||
|
||||
// Verify security token
|
||||
if (!checkSecurityToken()) {
|
||||
http_status(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Security token mismatch']);
|
||||
return;
|
||||
}
|
||||
|
||||
$action = $INPUT->str('action'); // 'complete' or 'reopen'
|
||||
$uid = $INPUT->str('uid');
|
||||
$dateIso = $INPUT->str('date');
|
||||
$recurrence = $INPUT->str('recurrence');
|
||||
|
||||
if (!in_array($action, ['complete', 'reopen'], true)) {
|
||||
http_status(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid action']);
|
||||
return;
|
||||
}
|
||||
if ($uid === '' || !ChronoID::isIsoDate($dateIso)) {
|
||||
http_status(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Missing uid or date']);
|
||||
return;
|
||||
}
|
||||
|
||||
$slots = CalendarSlot::loadAll($this);
|
||||
$maintenanceSlot = $slots['maintenance'] ?? null;
|
||||
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
|
||||
http_status(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Maintenance calendar not configured']);
|
||||
return;
|
||||
}
|
||||
|
||||
$newStatus = ($action === 'complete') ? 'COMPLETED' : 'TODO';
|
||||
|
||||
// Update local ICS file
|
||||
$localOk = false;
|
||||
$file = $maintenanceSlot->getFile();
|
||||
if ($file !== '' && is_file($file)) {
|
||||
$localOk = IcsWriter::updateEventStatus($file, $uid, $recurrence, $newStatus, $dateIso);
|
||||
}
|
||||
|
||||
if (!$localOk) {
|
||||
http_status(500);
|
||||
echo json_encode(['ok' => false, 'error' => $this->getLang('maintenance_complete_error')]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear caches so next render picks up changes
|
||||
CalendarService::clearCache();
|
||||
|
||||
// Remote CalDAV write-back if configured
|
||||
$remoteOk = true;
|
||||
$remoteError = '';
|
||||
if ($maintenanceSlot->hasRemoteSource()) {
|
||||
try {
|
||||
$caldavOk = CalDavClient::updateEventStatus(
|
||||
$maintenanceSlot->getCaldavUrl(),
|
||||
$maintenanceSlot->getUsername(),
|
||||
$maintenanceSlot->getPassword(),
|
||||
$uid,
|
||||
$recurrence,
|
||||
$newStatus,
|
||||
$dateIso
|
||||
);
|
||||
if (!$caldavOk) {
|
||||
$remoteOk = false;
|
||||
$remoteError = $this->getLang('maintenance_remote_write_failed');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$remoteOk = false;
|
||||
$remoteError = $this->getLang('maintenance_remote_write_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$msg = ($action === 'complete')
|
||||
? $this->getLang('maintenance_complete_success')
|
||||
: $this->getLang('maintenance_reopen_success');
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'message' => $msg,
|
||||
'remoteOk' => $remoteOk,
|
||||
'remoteError' => $remoteError,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX requests for manual calendar sync.
|
||||
*
|
||||
* @param Event $event
|
||||
* @param mixed $param
|
||||
* @return void
|
||||
*/
|
||||
public function handleCalendarSyncAction(Event $event, $param)
|
||||
{
|
||||
if ($event->data !== 'luxtools_calendar_sync') return;
|
||||
|
||||
$event->preventDefault();
|
||||
$event->stopPropagation();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$this->sendNoStoreHeaders();
|
||||
|
||||
global $INPUT;
|
||||
|
||||
if (!checkSecurityToken()) {
|
||||
http_status(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Security token mismatch']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!function_exists('auth_isadmin') || !auth_isadmin()) {
|
||||
http_status(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Admin access required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$slots = CalendarSlot::loadEnabled($this);
|
||||
$results = [];
|
||||
$hasErrors = false;
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->hasRemoteSource()) continue;
|
||||
|
||||
$ok = CalDavClient::syncSlot($slot);
|
||||
$results[$slot->getKey()] = $ok;
|
||||
if (!$ok) $hasErrors = true;
|
||||
}
|
||||
|
||||
CalendarService::clearCache();
|
||||
|
||||
$msg = $hasErrors
|
||||
? $this->getLang('calendar_sync_partial')
|
||||
: $this->getLang('calendar_sync_success');
|
||||
|
||||
echo json_encode([
|
||||
'ok' => !$hasErrors,
|
||||
'message' => $msg,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build wiki bullet list for local calendar events.
|
||||
*
|
||||
@@ -510,23 +836,20 @@ class action_plugin_luxtools extends ActionPlugin
|
||||
*/
|
||||
protected function buildChronologicalEventsWiki(string $dateIso): string
|
||||
{
|
||||
$icsConfig = (string)$this->getConf('calendar_ics_files');
|
||||
if (trim($icsConfig) === '') return '';
|
||||
$slots = CalendarSlot::loadEnabled($this);
|
||||
if ($slots === []) return '';
|
||||
|
||||
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
|
||||
$events = CalendarService::eventsForDate($slots, $dateIso);
|
||||
if ($events === []) return '';
|
||||
|
||||
$lines = [];
|
||||
foreach ($events as $event) {
|
||||
$summary = trim((string)($event['summary'] ?? ''));
|
||||
if ($summary === '') $summary = '(ohne Titel)';
|
||||
$summary = str_replace(["\n", "\r"], ' ', $summary);
|
||||
$summary = str_replace(["\n", "\r"], ' ', $event->summary);
|
||||
|
||||
$time = trim((string)($event['time'] ?? ''));
|
||||
if ((bool)($event['allDay'] ?? false) || $time === '') {
|
||||
if ($event->allDay || $event->time === '') {
|
||||
$lines[] = ' * ' . $summary;
|
||||
} else {
|
||||
$lines[] = ' * ' . $time . ' - ' . $summary;
|
||||
$lines[] = ' * ' . $event->time . ' - ' . $summary;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ if (!defined('DOKU_INC')) die();
|
||||
|
||||
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
{
|
||||
/** @var string[] Calendar slot keys */
|
||||
protected $calendarSlotKeys = ['general', 'maintenance', 'slot3', 'slot4'];
|
||||
|
||||
/** @var string[] */
|
||||
protected $configKeys = [
|
||||
'paths',
|
||||
@@ -29,7 +32,26 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
'gallery_thumb_scale',
|
||||
'open_service_url',
|
||||
'image_base_path',
|
||||
'calendar_ics_files',
|
||||
'calendar_general_file',
|
||||
'calendar_general_caldav_url',
|
||||
'calendar_general_username',
|
||||
'calendar_general_password',
|
||||
'calendar_general_color',
|
||||
'calendar_maintenance_file',
|
||||
'calendar_maintenance_caldav_url',
|
||||
'calendar_maintenance_username',
|
||||
'calendar_maintenance_password',
|
||||
'calendar_maintenance_color',
|
||||
'calendar_slot3_file',
|
||||
'calendar_slot3_caldav_url',
|
||||
'calendar_slot3_username',
|
||||
'calendar_slot3_password',
|
||||
'calendar_slot3_color',
|
||||
'calendar_slot4_file',
|
||||
'calendar_slot4_caldav_url',
|
||||
'calendar_slot4_username',
|
||||
'calendar_slot4_password',
|
||||
'calendar_slot4_color',
|
||||
'pagelink_search_depth',
|
||||
];
|
||||
|
||||
@@ -90,9 +112,14 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
$newConf['open_service_url'] = $INPUT->str('open_service_url');
|
||||
$newConf['image_base_path'] = $INPUT->str('image_base_path');
|
||||
|
||||
$icsFiles = $INPUT->str('calendar_ics_files');
|
||||
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles);
|
||||
$newConf['calendar_ics_files'] = $icsFiles;
|
||||
// Calendar slot settings
|
||||
foreach ($this->calendarSlotKeys as $slot) {
|
||||
$newConf['calendar_' . $slot . '_file'] = trim($INPUT->str('calendar_' . $slot . '_file'));
|
||||
$newConf['calendar_' . $slot . '_caldav_url'] = trim($INPUT->str('calendar_' . $slot . '_caldav_url'));
|
||||
$newConf['calendar_' . $slot . '_username'] = trim($INPUT->str('calendar_' . $slot . '_username'));
|
||||
$newConf['calendar_' . $slot . '_password'] = trim($INPUT->str('calendar_' . $slot . '_password'));
|
||||
$newConf['calendar_' . $slot . '_color'] = trim($INPUT->str('calendar_' . $slot . '_color'));
|
||||
}
|
||||
|
||||
$depth = (int)$INPUT->int('pagelink_search_depth');
|
||||
if ($depth < 0) $depth = 0;
|
||||
@@ -240,12 +267,39 @@ 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 '</label><br />';
|
||||
|
||||
// calendar_ics_files
|
||||
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
|
||||
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
|
||||
// Calendar slot settings
|
||||
$slotLabels = [
|
||||
'general' => 'General',
|
||||
'maintenance' => 'Maintenance',
|
||||
'slot3' => 'Slot 3',
|
||||
'slot4' => 'Slot 4',
|
||||
];
|
||||
foreach ($this->calendarSlotKeys as $slot) {
|
||||
echo '<h2>' . hsc($this->getLang('calendar_slot_heading') . ': ' . $slotLabels[$slot]) . '</h2>';
|
||||
|
||||
$prefix = 'calendar_' . $slot . '_';
|
||||
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_file')) . '</span> ';
|
||||
echo '<input type="text" class="edit" name="' . hsc($prefix . 'file') . '" value="' . hsc((string)$this->getConf($prefix . 'file')) . '" />';
|
||||
echo '</label><br />';
|
||||
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_caldav_url')) . '</span> ';
|
||||
echo '<input type="text" class="edit" name="' . hsc($prefix . 'caldav_url') . '" value="' . hsc((string)$this->getConf($prefix . 'caldav_url')) . '" />';
|
||||
echo '</label><br />';
|
||||
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_username')) . '</span> ';
|
||||
echo '<input type="text" class="edit" name="' . hsc($prefix . 'username') . '" value="' . hsc((string)$this->getConf($prefix . 'username')) . '" />';
|
||||
echo '</label><br />';
|
||||
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_password')) . '</span> ';
|
||||
echo '<input type="password" class="edit" name="' . hsc($prefix . 'password') . '" value="' . hsc((string)$this->getConf($prefix . 'password')) . '" />';
|
||||
echo '</label><br />';
|
||||
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_color')) . '</span> ';
|
||||
echo '<input type="color" name="' . hsc($prefix . 'color') . '" value="' . hsc((string)$this->getConf($prefix . 'color') ?: '#999999') . '" />';
|
||||
echo '</label><br />';
|
||||
}
|
||||
|
||||
// pagelink_search_depth
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
||||
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
||||
|
||||
@@ -37,8 +37,31 @@ $conf['open_service_url'] = 'http://127.0.0.1:8765';
|
||||
// Base filesystem path for chronological photo integration.
|
||||
$conf['image_base_path'] = '';
|
||||
|
||||
// Local calendar ICS files (one absolute file path per line).
|
||||
$conf['calendar_ics_files'] = '';
|
||||
// Calendar slot configuration (4 slots: general, maintenance, slot3, slot4)
|
||||
// Each slot has: file, caldav_url, username, password, color
|
||||
$conf['calendar_general_file'] = '';
|
||||
$conf['calendar_general_caldav_url'] = '';
|
||||
$conf['calendar_general_username'] = '';
|
||||
$conf['calendar_general_password'] = '';
|
||||
$conf['calendar_general_color'] = '#4a90d9';
|
||||
|
||||
$conf['calendar_maintenance_file'] = '';
|
||||
$conf['calendar_maintenance_caldav_url'] = '';
|
||||
$conf['calendar_maintenance_username'] = '';
|
||||
$conf['calendar_maintenance_password'] = '';
|
||||
$conf['calendar_maintenance_color'] = '#e67e22';
|
||||
|
||||
$conf['calendar_slot3_file'] = '';
|
||||
$conf['calendar_slot3_caldav_url'] = '';
|
||||
$conf['calendar_slot3_username'] = '';
|
||||
$conf['calendar_slot3_password'] = '';
|
||||
$conf['calendar_slot3_color'] = '#27ae60';
|
||||
|
||||
$conf['calendar_slot4_file'] = '';
|
||||
$conf['calendar_slot4_caldav_url'] = '';
|
||||
$conf['calendar_slot4_username'] = '';
|
||||
$conf['calendar_slot4_password'] = '';
|
||||
$conf['calendar_slot4_color'] = '#8e44ad';
|
||||
|
||||
// Maximum depth when searching for .pagelink files under allowed roots.
|
||||
$conf['pagelink_search_depth'] = 3;
|
||||
|
||||
303
js/event-popup.js
Normal file
303
js/event-popup.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/* global window, document, jQuery */
|
||||
|
||||
/**
|
||||
* Event Popup and Maintenance Task Handling
|
||||
*
|
||||
* - Clicking an event item with data-luxtools-event="1" opens a detail popup.
|
||||
* - Clicking a maintenance task action button sends an AJAX request to
|
||||
* complete/reopen the task.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||
|
||||
// ============================================================
|
||||
// Event Popup
|
||||
// ============================================================
|
||||
var EventPopup = (function () {
|
||||
var overlay = null;
|
||||
var popup = null;
|
||||
|
||||
function ensureElements() {
|
||||
if (overlay) return;
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'luxtools-event-popup-overlay';
|
||||
overlay.style.display = 'none';
|
||||
|
||||
popup = document.createElement('div');
|
||||
popup.className = 'luxtools-event-popup';
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-modal', 'true');
|
||||
|
||||
overlay.appendChild(popup);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && overlay.style.display !== 'none') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open(el) {
|
||||
ensureElements();
|
||||
|
||||
var summary = el.getAttribute('data-event-summary') || '';
|
||||
var start = el.getAttribute('data-event-start') || '';
|
||||
var end = el.getAttribute('data-event-end') || '';
|
||||
var location = el.getAttribute('data-event-location') || '';
|
||||
var description = el.getAttribute('data-event-description') || '';
|
||||
var allDay = el.getAttribute('data-event-allday') === '1';
|
||||
var slot = el.getAttribute('data-event-slot') || '';
|
||||
|
||||
var html = '<div class="luxtools-event-popup-content">';
|
||||
html += '<button type="button" class="luxtools-event-popup-close" aria-label="Close">×</button>';
|
||||
html += '<h3 class="luxtools-event-popup-title">' + escapeHtml(summary) + '</h3>';
|
||||
|
||||
// Date/time
|
||||
html += '<div class="luxtools-event-popup-field">';
|
||||
if (allDay) {
|
||||
html += '<strong>Date:</strong> ' + formatDate(start);
|
||||
if (end) {
|
||||
html += ' – ' + formatDate(end);
|
||||
}
|
||||
} else {
|
||||
html += '<strong>Time:</strong> ' + formatDateTime(start);
|
||||
if (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 d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDateTime(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
var d = new Date(isoStr);
|
||||
if (isNaN(d.getTime())) return isoStr;
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(text));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
return {
|
||||
open: open,
|
||||
close: close,
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Maintenance Task Actions
|
||||
// ============================================================
|
||||
var MaintenanceTasks = (function () {
|
||||
|
||||
function handleAction(button) {
|
||||
var action = button.getAttribute('data-action');
|
||||
if (!action) return;
|
||||
|
||||
// Find the containing list item or container with task data
|
||||
var item = button.closest('[data-task-uid]');
|
||||
if (!item) {
|
||||
// Try the syntax plugin format
|
||||
item = button.closest('[data-uid]');
|
||||
}
|
||||
if (!item) return;
|
||||
|
||||
var uid = item.getAttribute('data-task-uid') || item.getAttribute('data-uid') || '';
|
||||
var date = item.getAttribute('data-task-date') || item.getAttribute('data-date') || '';
|
||||
var recurrence = item.getAttribute('data-task-recurrence') || item.getAttribute('data-recurrence') || '';
|
||||
|
||||
if (!uid || !date) return;
|
||||
|
||||
// Find AJAX URL and security token from parent container or global
|
||||
var container = item.closest('[data-luxtools-ajax-url]');
|
||||
var ajaxUrl = container ? container.getAttribute('data-luxtools-ajax-url') : '';
|
||||
var sectok = container ? container.getAttribute('data-luxtools-sectok') : '';
|
||||
|
||||
if (!ajaxUrl) {
|
||||
// Fallback: use DokuWiki's standard AJAX endpoint
|
||||
ajaxUrl = (window.DOKU_BASE || '/') + 'lib/exe/ajax.php';
|
||||
}
|
||||
if (!sectok && window.JSINFO && window.JSINFO.sectok) {
|
||||
sectok = window.JSINFO.sectok;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = '...';
|
||||
|
||||
var params = 'call=luxtools_maintenance_task'
|
||||
+ '&action=' + encodeURIComponent(action)
|
||||
+ '&uid=' + encodeURIComponent(uid)
|
||||
+ '&date=' + encodeURIComponent(date)
|
||||
+ '&recurrence=' + encodeURIComponent(recurrence)
|
||||
+ '§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');
|
||||
// Fade out and remove after a short delay
|
||||
item.style.opacity = '0.5';
|
||||
setTimeout(function () {
|
||||
item.style.display = 'none';
|
||||
}, 1000);
|
||||
} else {
|
||||
item.classList.remove('luxtools-task-completed');
|
||||
item.style.opacity = '1';
|
||||
button.textContent = 'Complete';
|
||||
button.setAttribute('data-action', 'complete');
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
// Show remote write warning if applicable
|
||||
if (result.remoteOk === false && result.remoteError) {
|
||||
showNotification(result.remoteError, 'warning');
|
||||
}
|
||||
} else {
|
||||
var errMsg = result.error || 'Action failed';
|
||||
showNotification(errMsg, 'error');
|
||||
button.textContent = action === 'complete' ? 'Complete' : 'Reopen';
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
showNotification('Network error', 'error');
|
||||
button.textContent = action === 'complete' ? 'Complete' : 'Reopen';
|
||||
button.disabled = false;
|
||||
};
|
||||
|
||||
xhr.send(params);
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
// Use DokuWiki msg() if available
|
||||
if (typeof window.msg === 'function') {
|
||||
var level = (type === 'error') ? -1 : ((type === 'warning') ? 0 : 1);
|
||||
window.msg(message, level);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: simple notification
|
||||
var notif = document.createElement('div');
|
||||
notif.className = 'luxtools-notification luxtools-notification-' + type;
|
||||
notif.textContent = message;
|
||||
document.body.appendChild(notif);
|
||||
setTimeout(function () {
|
||||
if (notif.parentNode) notif.parentNode.removeChild(notif);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return {
|
||||
handleAction: handleAction,
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Event Delegation
|
||||
// ============================================================
|
||||
document.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
|
||||
// Maintenance task action buttons (day pages)
|
||||
if (target.classList && target.classList.contains('luxtools-task-action')) {
|
||||
e.preventDefault();
|
||||
MaintenanceTasks.handleAction(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Maintenance task complete buttons (syntax plugin list)
|
||||
if (target.classList && target.classList.contains('luxtools-task-complete-btn')) {
|
||||
e.preventDefault();
|
||||
MaintenanceTasks.handleAction(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Event popup: find closest element with data-luxtools-event
|
||||
var eventEl = target.closest ? target.closest('[data-luxtools-event]') : null;
|
||||
if (!eventEl) {
|
||||
// Traverse manually for older browsers
|
||||
var el = target;
|
||||
while (el && el !== document) {
|
||||
if (el.getAttribute && el.getAttribute('data-luxtools-event') === '1') {
|
||||
eventEl = el;
|
||||
break;
|
||||
}
|
||||
el = el.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventEl) {
|
||||
// Don't open popup if clicking a button inside the event item
|
||||
if (target.tagName === 'BUTTON' || target.closest('button')) return;
|
||||
|
||||
e.preventDefault();
|
||||
EventPopup.open(eventEl);
|
||||
}
|
||||
}, false);
|
||||
|
||||
Luxtools.EventPopup = EventPopup;
|
||||
Luxtools.MaintenanceTasks = MaintenanceTasks;
|
||||
})();
|
||||
@@ -64,8 +64,12 @@ $lang["open_service_url"] =
|
||||
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
|
||||
$lang["image_base_path"] =
|
||||
"Basis-Dateisystempfad für die chronologische Foto-Integration.";
|
||||
$lang["calendar_ics_files"] =
|
||||
"Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile).";
|
||||
$lang["calendar_slot_heading"] = "Kalender-Slot";
|
||||
$lang["calendar_slot_file"] = "Lokaler ICS-Dateipfad";
|
||||
$lang["calendar_slot_caldav_url"] = "CalDAV-URL";
|
||||
$lang["calendar_slot_username"] = "Benutzername";
|
||||
$lang["calendar_slot_password"] = "Passwort";
|
||||
$lang["calendar_slot_color"] = "Anzeigefarbe";
|
||||
$lang["pagelink_search_depth"] =
|
||||
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
|
||||
|
||||
@@ -85,6 +89,18 @@ $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
|
||||
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
|
||||
$lang["chronological_photos_title"] = "Fotos";
|
||||
$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_title"] = "Gesamten DokuWiki-Cache leeren";
|
||||
$lang["cache_invalidate_success"] = "DokuWiki-Cache invalidiert.";
|
||||
|
||||
@@ -64,8 +64,12 @@ $lang["open_service_url"] =
|
||||
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
|
||||
$lang["image_base_path"] =
|
||||
"Base filesystem path for chronological photo integration.";
|
||||
$lang["calendar_ics_files"] =
|
||||
"Local calendar .ics files (one absolute file path per line).";
|
||||
$lang["calendar_slot_heading"] = "Calendar Slot";
|
||||
$lang["calendar_slot_file"] = "Local ICS file path";
|
||||
$lang["calendar_slot_caldav_url"] = "CalDAV URL";
|
||||
$lang["calendar_slot_username"] = "Username";
|
||||
$lang["calendar_slot_password"] = "Password";
|
||||
$lang["calendar_slot_color"] = "Display color";
|
||||
$lang["pagelink_search_depth"] =
|
||||
"Maximum directory depth for .pagelink search (0 = only root).";
|
||||
|
||||
@@ -86,6 +90,18 @@ $lang["pagelink_multi_warning"] = "Multiple folders linked";
|
||||
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
|
||||
$lang["chronological_photos_title"] = "Photos";
|
||||
$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_title"] = "Purge the entire DokuWiki cache";
|
||||
$lang["cache_invalidate_success"] = "DokuWiki cache invalidated.";
|
||||
|
||||
466
src/CalDavClient.php
Normal file
466
src/CalDavClient.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Reader;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* CalDAV client for remote calendar operations.
|
||||
*
|
||||
* Supports:
|
||||
* - Downloading a full calendar collection into a local ICS file (sync)
|
||||
* - Updating the STATUS of a single event/task occurrence on the remote server
|
||||
*
|
||||
* Uses plain PHP curl for HTTP. No additional dependencies required.
|
||||
*/
|
||||
class CalDavClient
|
||||
{
|
||||
/** @var int HTTP timeout in seconds */
|
||||
protected const TIMEOUT = 30;
|
||||
|
||||
/**
|
||||
* Update the STATUS of a specific event or task on the remote CalDAV server.
|
||||
*
|
||||
* Fetches the calendar object containing the UID, modifies its status,
|
||||
* and PUTs it back using the ETag for conflict detection.
|
||||
*
|
||||
* @param string $caldavUrl CalDAV collection URL
|
||||
* @param string $username HTTP Basic auth username
|
||||
* @param string $password HTTP Basic auth password
|
||||
* @param string $uid Event/task UID
|
||||
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
|
||||
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
|
||||
* @param string $dateIso Occurrence date YYYY-MM-DD
|
||||
* @return bool True if the remote update succeeded
|
||||
*/
|
||||
public static function updateEventStatus(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
if ($caldavUrl === '' || $uid === '') return false;
|
||||
|
||||
try {
|
||||
// Find the calendar object href for this UID via REPORT
|
||||
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||
if ($objectInfo === null) return false;
|
||||
|
||||
$objectHref = $objectInfo['href'];
|
||||
$etag = $objectInfo['etag'];
|
||||
$calendarData = $objectInfo['data'];
|
||||
|
||||
// Parse and update the status
|
||||
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
|
||||
if (!($calendar instanceof VCalendar)) return false;
|
||||
|
||||
$updated = IcsWriter::applyStatusUpdateToCalendar(
|
||||
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
|
||||
);
|
||||
if (!$updated) return false;
|
||||
|
||||
$newData = $calendar->serialize();
|
||||
|
||||
// PUT the updated object back with If-Match for conflict detection
|
||||
return self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a remote CalDAV calendar collection into the slot's local ICS file.
|
||||
*
|
||||
* Downloads all calendar objects from the collection and merges them
|
||||
* into a single ICS file at the slot's configured file path.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @return bool True if sync succeeded
|
||||
*/
|
||||
public static function syncSlot(CalendarSlot $slot): bool
|
||||
{
|
||||
if (!$slot->hasRemoteSource()) return false;
|
||||
|
||||
$caldavUrl = $slot->getCaldavUrl();
|
||||
$username = $slot->getUsername();
|
||||
$password = $slot->getPassword();
|
||||
$localFile = $slot->getFile();
|
||||
|
||||
if ($localFile === '') {
|
||||
// No local file configured - nothing to sync into
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password);
|
||||
if ($objects === null) return false;
|
||||
|
||||
$merged = self::mergeCalendarObjects($objects);
|
||||
if ($merged === '') return false;
|
||||
|
||||
return IcsWriter::atomicWritePublic($localFile, $merged);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific calendar object by UID using a REPORT request.
|
||||
*
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $uid
|
||||
* @return array{href: string, etag: string, data: string}|null
|
||||
*/
|
||||
protected static function findObjectByUid(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid
|
||||
): ?array {
|
||||
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||
'<D:prop>' .
|
||||
'<D:getetag/>' .
|
||||
'<C:calendar-data/>' .
|
||||
'</D:prop>' .
|
||||
'<C:filter>' .
|
||||
'<C:comp-filter name="VCALENDAR">' .
|
||||
'<C:comp-filter name="VEVENT">' .
|
||||
'<C:prop-filter name="UID">' .
|
||||
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
|
||||
'</C:prop-filter>' .
|
||||
'</C:comp-filter>' .
|
||||
'</C:comp-filter>' .
|
||||
'</C:filter>' .
|
||||
'</C:calendar-query>';
|
||||
|
||||
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
|
||||
'Content-Type: application/xml; charset=utf-8',
|
||||
'Depth: 1',
|
||||
]);
|
||||
|
||||
if ($response === null) {
|
||||
// Try VTODO filter as well
|
||||
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||
'<D:prop>' .
|
||||
'<D:getetag/>' .
|
||||
'<C:calendar-data/>' .
|
||||
'</D:prop>' .
|
||||
'<C:filter>' .
|
||||
'<C:comp-filter name="VCALENDAR">' .
|
||||
'<C:comp-filter name="VTODO">' .
|
||||
'<C:prop-filter name="UID">' .
|
||||
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
|
||||
'</C:prop-filter>' .
|
||||
'</C:comp-filter>' .
|
||||
'</C:comp-filter>' .
|
||||
'</C:filter>' .
|
||||
'</C:calendar-query>';
|
||||
|
||||
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
|
||||
'Content-Type: application/xml; charset=utf-8',
|
||||
'Depth: 1',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($response === null) return null;
|
||||
|
||||
return self::parseReportResponse($response, $caldavUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all calendar objects from a CalDAV collection.
|
||||
*
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @return string[]|null Array of ICS data strings, or null on failure
|
||||
*/
|
||||
protected static function fetchAllCalendarObjects(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password
|
||||
): ?array {
|
||||
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||
'<D:prop>' .
|
||||
'<C:calendar-data/>' .
|
||||
'</D:prop>' .
|
||||
'<C:filter>' .
|
||||
'<C:comp-filter name="VCALENDAR"/>' .
|
||||
'</C:filter>' .
|
||||
'</C:calendar-query>';
|
||||
|
||||
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
|
||||
'Content-Type: application/xml; charset=utf-8',
|
||||
'Depth: 1',
|
||||
]);
|
||||
|
||||
if ($response === null) return null;
|
||||
|
||||
return self::parseCalendarDataFromMultistatus($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple ICS calendar objects into a single calendar string.
|
||||
*
|
||||
* @param string[] $objects Array of ICS data strings
|
||||
* @return string Merged ICS content
|
||||
*/
|
||||
protected static function mergeCalendarObjects(array $objects): string
|
||||
{
|
||||
if ($objects === []) return '';
|
||||
|
||||
$merged = new VCalendar();
|
||||
$merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN';
|
||||
$merged->VERSION = '2.0';
|
||||
|
||||
foreach ($objects as $icsData) {
|
||||
if (trim($icsData) === '') continue;
|
||||
try {
|
||||
$cal = Reader::read($icsData, Reader::OPTION_FORGIVING);
|
||||
if (!($cal instanceof VCalendar)) continue;
|
||||
|
||||
// Copy VTIMEZONE components first
|
||||
foreach ($cal->select('VTIMEZONE') as $tz) {
|
||||
// Check if this timezone already exists in merged
|
||||
$tzid = (string)($tz->TZID ?? '');
|
||||
$exists = false;
|
||||
foreach ($merged->select('VTIMEZONE') as $existingTz) {
|
||||
if ((string)($existingTz->TZID ?? '') === $tzid) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$merged->add(clone $tz);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy VEVENT and VTODO components
|
||||
foreach ($cal->select('VEVENT') as $component) {
|
||||
$merged->add(clone $component);
|
||||
}
|
||||
foreach ($cal->select('VTODO') as $component) {
|
||||
$merged->add(clone $component);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Skip malformed objects
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $merged->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a calendar object back to the server.
|
||||
*
|
||||
* @param string $href Full URL of the calendar object
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $data ICS data to write
|
||||
* @param string $etag ETag for If-Match header (empty to skip)
|
||||
* @return bool
|
||||
*/
|
||||
protected static function putCalendarObject(
|
||||
string $href,
|
||||
string $username,
|
||||
string $password,
|
||||
string $data,
|
||||
string $etag
|
||||
): bool {
|
||||
$headers = [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
];
|
||||
if ($etag !== '') {
|
||||
$headers[] = 'If-Match: ' . $etag;
|
||||
}
|
||||
|
||||
$response = self::request('PUT', $href, $username, $password, $data, $headers);
|
||||
// PUT returns null body but we check by HTTP status via the request method
|
||||
// A successful PUT returns 2xx
|
||||
return $response !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a REPORT multistatus response to extract href, etag, and calendar data
|
||||
* for the first matching object.
|
||||
*
|
||||
* @param string $xml
|
||||
* @param string $baseUrl
|
||||
* @return array{href: string, etag: string, data: string}|null
|
||||
*/
|
||||
protected static function parseReportResponse(string $xml, string $baseUrl): ?array
|
||||
{
|
||||
$doc = self::parseXml($xml);
|
||||
if ($doc === null) return null;
|
||||
|
||||
$doc->registerXPathNamespace('d', 'DAV:');
|
||||
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$responses = $doc->xpath('//d:response');
|
||||
if (!$responses || count($responses) === 0) return null;
|
||||
|
||||
foreach ($responses as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$hrefs = $resp->xpath('d:href');
|
||||
$href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : '';
|
||||
|
||||
$etags = $resp->xpath('.//d:getetag');
|
||||
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
|
||||
// Strip surrounding quotes from etag if present
|
||||
$etag = trim($etag, '"');
|
||||
|
||||
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
|
||||
|
||||
if ($href === '' || $data === '') continue;
|
||||
|
||||
// Resolve relative href to absolute URL
|
||||
if (strpos($href, 'http') !== 0) {
|
||||
$parsed = parse_url($baseUrl);
|
||||
$scheme = ($parsed['scheme'] ?? 'https');
|
||||
$host = ($parsed['host'] ?? '');
|
||||
$port = isset($parsed['port']) ? (':' . $parsed['port']) : '';
|
||||
$href = $scheme . '://' . $host . $port . $href;
|
||||
}
|
||||
|
||||
return [
|
||||
'href' => $href,
|
||||
'etag' => $etag,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse calendar-data elements from a CalDAV multistatus response.
|
||||
*
|
||||
* @param string $xml
|
||||
* @return string[]
|
||||
*/
|
||||
protected static function parseCalendarDataFromMultistatus(string $xml): array
|
||||
{
|
||||
$doc = self::parseXml($xml);
|
||||
if ($doc === null) return [];
|
||||
|
||||
$doc->registerXPathNamespace('d', 'DAV:');
|
||||
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$results = [];
|
||||
$responses = $doc->xpath('//d:response');
|
||||
if (!$responses) return [];
|
||||
|
||||
foreach ($responses as $resp) {
|
||||
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||
if ($caldata && count($caldata) > 0) {
|
||||
$data = trim((string)$caldata[0]);
|
||||
if ($data !== '') {
|
||||
$results[] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an XML string safely.
|
||||
*
|
||||
* @param string $xml
|
||||
* @return \SimpleXMLElement|null
|
||||
*/
|
||||
protected static function parseXml(string $xml): ?\SimpleXMLElement
|
||||
{
|
||||
if (trim($xml) === '') return null;
|
||||
|
||||
// Disable external entity loading for security
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
try {
|
||||
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
|
||||
libxml_clear_errors();
|
||||
return ($doc !== false) ? $doc : null;
|
||||
} finally {
|
||||
libxml_use_internal_errors($prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an HTTP request to a CalDAV server.
|
||||
*
|
||||
* @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.)
|
||||
* @param string $url Full URL
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $body Request body (empty for GET)
|
||||
* @param string[] $headers Additional HTTP headers
|
||||
* @return string|null Response body, or null on failure
|
||||
*/
|
||||
protected static function request(
|
||||
string $method,
|
||||
string $url,
|
||||
string $username,
|
||||
string $password,
|
||||
string $body = '',
|
||||
array $headers = []
|
||||
): ?string {
|
||||
if (!function_exists('curl_init')) return null;
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) return null;
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
|
||||
|
||||
// Authentication
|
||||
if ($username !== '') {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
|
||||
}
|
||||
|
||||
// Request body
|
||||
if ($body !== '') {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
// HTTP headers
|
||||
if ($headers !== []) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
}
|
||||
|
||||
// Capture HTTP status code
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!is_string($responseBody)) return null;
|
||||
|
||||
// Accept 2xx and 207 (multistatus) responses
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return $responseBody;
|
||||
}
|
||||
if ($httpCode === 207) {
|
||||
return $responseBody;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
86
src/CalendarEvent.php
Normal file
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 or VTODO */
|
||||
public $componentType;
|
||||
|
||||
/** @var string The date (YYYY-MM-DD) this event applies to */
|
||||
public $dateIso;
|
||||
|
||||
/**
|
||||
* Build a stable completion key for maintenance task tracking.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function completionKey(): string
|
||||
{
|
||||
return implode('|', [$this->slotKey, $this->uid, $this->dateIso]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event/task is marked as completed.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
$s = strtoupper($this->status);
|
||||
return $s === 'COMPLETED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event/task is open (for maintenance filtering).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return !$this->isCompleted();
|
||||
}
|
||||
}
|
||||
553
src/CalendarService.php
Normal file
553
src/CalendarService.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Component\VEvent;
|
||||
use Sabre\VObject\Component\VTodo;
|
||||
use Sabre\VObject\Reader;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Slot-aware calendar service.
|
||||
*
|
||||
* Provides normalized event data grouped by slot for rendering,
|
||||
* widget indicators, task list queries, and completion tracking.
|
||||
*/
|
||||
class CalendarService
|
||||
{
|
||||
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
|
||||
protected static $dayCache = [];
|
||||
|
||||
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
|
||||
protected static $taskCache = [];
|
||||
|
||||
/**
|
||||
* Get all normalized events for a given date across all enabled slots.
|
||||
*
|
||||
* @param CalendarSlot[] $slots Keyed by slot key
|
||||
* @param string $dateIso YYYY-MM-DD
|
||||
* @return CalendarEvent[] Sorted: all-day first, then by time, then by title
|
||||
*/
|
||||
public static function eventsForDate(array $slots, string $dateIso): array
|
||||
{
|
||||
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||
|
||||
$all = [];
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->isEnabled()) continue;
|
||||
$events = self::slotEventsForDate($slot, $dateIso);
|
||||
foreach ($events as $event) {
|
||||
$all[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
usort($all, [self::class, 'compareEvents']);
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific slot and date.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
public static function slotEventsForDate(CalendarSlot $slot, string $dateIso): array
|
||||
{
|
||||
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||
if (!$slot->isEnabled()) return [];
|
||||
|
||||
$cacheKey = $slot->getKey() . '|' . $dateIso;
|
||||
if (isset(self::$dayCache[$cacheKey])) {
|
||||
return self::$dayCache[$cacheKey];
|
||||
}
|
||||
|
||||
$events = [];
|
||||
$file = $slot->getFile();
|
||||
if ($file !== '' && is_file($file) && is_readable($file)) {
|
||||
$events = self::parseEventsFromFile($file, $slot->getKey(), $dateIso);
|
||||
}
|
||||
|
||||
self::$dayCache[$cacheKey] = $events;
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific slot on a date, grouped by slot key.
|
||||
*
|
||||
* @param CalendarSlot[] $slots
|
||||
* @param string $dateIso
|
||||
* @return array<string,CalendarEvent[]> Keyed by slot key
|
||||
*/
|
||||
public static function eventsForDateGrouped(array $slots, string $dateIso): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->isEnabled()) continue;
|
||||
$events = self::slotEventsForDate($slot, $dateIso);
|
||||
if ($events !== []) {
|
||||
$grouped[$slot->getKey()] = $events;
|
||||
}
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a slot has any events on a given date.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @param string $dateIso
|
||||
* @return bool
|
||||
*/
|
||||
public static function slotHasEventsOnDate(CalendarSlot $slot, string $dateIso): bool
|
||||
{
|
||||
return self::slotEventsForDate($slot, $dateIso) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open maintenance tasks due up to (and including) today.
|
||||
*
|
||||
* @param CalendarSlot $maintenanceSlot
|
||||
* @param string $todayIso YYYY-MM-DD
|
||||
* @return CalendarEvent[] Sorted: overdue first, then today, then by title
|
||||
*/
|
||||
public static function openMaintenanceTasks(CalendarSlot $maintenanceSlot, string $todayIso): array
|
||||
{
|
||||
if (!$maintenanceSlot->isEnabled()) return [];
|
||||
if (!ChronoID::isIsoDate($todayIso)) return [];
|
||||
|
||||
$file = $maintenanceSlot->getFile();
|
||||
if ($file === '' || !is_file($file) || !is_readable($file)) return [];
|
||||
|
||||
$cacheKey = $maintenanceSlot->getKey() . '|tasks|' . $todayIso;
|
||||
if (isset(self::$taskCache[$cacheKey])) {
|
||||
return self::$taskCache[$cacheKey];
|
||||
}
|
||||
|
||||
$tasks = self::parseAllTasksFromFile($file, $maintenanceSlot->getKey(), $todayIso);
|
||||
|
||||
// Filter: only non-completed, due today or earlier
|
||||
$open = [];
|
||||
foreach ($tasks as $task) {
|
||||
if ($task->isCompleted()) continue;
|
||||
// dateIso is the date the task falls on
|
||||
if ($task->dateIso > $todayIso) continue;
|
||||
$open[] = $task;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then today, then by time, then by title
|
||||
usort($open, static function (CalendarEvent $a, CalendarEvent $b) use ($todayIso): int {
|
||||
$aOverdue = $a->dateIso < $todayIso;
|
||||
$bOverdue = $b->dateIso < $todayIso;
|
||||
if ($aOverdue !== $bOverdue) {
|
||||
return $aOverdue ? -1 : 1;
|
||||
}
|
||||
$dateCmp = strcmp($a->dateIso, $b->dateIso);
|
||||
if ($dateCmp !== 0) return $dateCmp;
|
||||
|
||||
$timeCmp = strcmp($a->time, $b->time);
|
||||
if ($timeCmp !== 0) return $timeCmp;
|
||||
|
||||
return strcmp($a->summary, $b->summary);
|
||||
});
|
||||
|
||||
self::$taskCache[$cacheKey] = $open;
|
||||
return $open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot-level day indicator data for a whole month.
|
||||
*
|
||||
* @param CalendarSlot[] $slots
|
||||
* @param int $year
|
||||
* @param int $month
|
||||
* @return array<string,string[]> date => [slotKey, ...]
|
||||
*/
|
||||
public static function monthIndicators(array $slots, int $year, int $month): array
|
||||
{
|
||||
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$indicators = [];
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->isEnabled()) continue;
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
if (self::slotHasEventsOnDate($slot, $dateIso)) {
|
||||
$indicators[$dateIso][] = $slot->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse events from a local ICS file for a specific date.
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
|
||||
{
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || trim($raw) === '') return [];
|
||||
|
||||
try {
|
||||
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||
if (!($component instanceof VCalendar)) return [];
|
||||
|
||||
$utc = new DateTimeZone('UTC');
|
||||
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
|
||||
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
||||
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
|
||||
|
||||
$expanded = $component->expand($rangeStart, $rangeEnd);
|
||||
if (!($expanded instanceof VCalendar)) return [];
|
||||
|
||||
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all tasks (VEVENT with STATUS or VTODO) from a maintenance file,
|
||||
* expanding recurrences up to the given date.
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $slotKey
|
||||
* @param string $todayIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
|
||||
{
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || trim($raw) === '') return [];
|
||||
|
||||
try {
|
||||
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||
if (!($component instanceof VCalendar)) return [];
|
||||
|
||||
// Expand from a reasonable lookback to tomorrow
|
||||
$utc = new DateTimeZone('UTC');
|
||||
$rangeStart = new DateTimeImmutable('2020-01-01 00:00:00', $utc);
|
||||
$rangeEnd = new DateTimeImmutable($todayIso . ' 00:00:00', $utc);
|
||||
$rangeEnd = $rangeEnd->add(new DateInterval('P1D'));
|
||||
|
||||
$expanded = $component->expand($rangeStart, $rangeEnd);
|
||||
if (!($expanded instanceof VCalendar)) return [];
|
||||
|
||||
$tasks = [];
|
||||
|
||||
// Collect VEVENTs
|
||||
foreach ($expanded->select('VEVENT') as $vevent) {
|
||||
if (!($vevent instanceof VEvent)) continue;
|
||||
$event = self::normalizeVEvent($vevent, $slotKey);
|
||||
if ($event !== null) {
|
||||
$tasks[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect VTODOs
|
||||
foreach ($expanded->select('VTODO') as $vtodo) {
|
||||
if (!($vtodo instanceof VTodo)) continue;
|
||||
$event = self::normalizeVTodo($vtodo, $slotKey);
|
||||
if ($event !== null) {
|
||||
$tasks[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect normalized events from an expanded VCalendar for a specific date.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array
|
||||
{
|
||||
$result = [];
|
||||
$seen = [];
|
||||
|
||||
// VEVENTs
|
||||
foreach ($calendar->select('VEVENT') as $vevent) {
|
||||
if (!($vevent instanceof VEvent)) continue;
|
||||
$event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso);
|
||||
if ($event === null) continue;
|
||||
|
||||
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
|
||||
if (isset($seen[$dedupeKey])) continue;
|
||||
$seen[$dedupeKey] = true;
|
||||
$result[] = $event;
|
||||
}
|
||||
|
||||
// VTODOs
|
||||
foreach ($calendar->select('VTODO') as $vtodo) {
|
||||
if (!($vtodo instanceof VTodo)) continue;
|
||||
$event = self::normalizeVTodoForDay($vtodo, $slotKey, $dateIso);
|
||||
if ($event === null) continue;
|
||||
|
||||
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
|
||||
if (isset($seen[$dedupeKey])) continue;
|
||||
$seen[$dedupeKey] = true;
|
||||
$result[] = $event;
|
||||
}
|
||||
|
||||
usort($result, [self::class, 'compareEvents']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VEVENT for a specific day into a CalendarEvent.
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent
|
||||
{
|
||||
if (!isset($vevent->DTSTART)) return null;
|
||||
|
||||
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||
$start = self::toImmutable($vevent->DTSTART->getDateTime());
|
||||
if ($start === null) return null;
|
||||
|
||||
$end = self::resolveEnd($vevent, $start, $isAllDay);
|
||||
|
||||
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
|
||||
|
||||
$event = new CalendarEvent();
|
||||
$event->slotKey = $slotKey;
|
||||
$event->uid = trim((string)($vevent->UID ?? ''));
|
||||
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
|
||||
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||
$event->startIso = $start->format(DateTimeInterface::ATOM);
|
||||
$event->endIso = $end->format(DateTimeInterface::ATOM);
|
||||
$event->allDay = $isAllDay;
|
||||
$event->time = $isAllDay ? '' : $start->format('H:i');
|
||||
$event->location = trim((string)($vevent->LOCATION ?? ''));
|
||||
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
|
||||
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
|
||||
$event->componentType = 'VEVENT';
|
||||
$event->dateIso = $dateIso;
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VEVENT into a CalendarEvent (without day filtering).
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param string $slotKey
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVEvent(VEvent $vevent, string $slotKey): ?CalendarEvent
|
||||
{
|
||||
if (!isset($vevent->DTSTART)) return null;
|
||||
|
||||
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||
$start = self::toImmutable($vevent->DTSTART->getDateTime());
|
||||
if ($start === null) return null;
|
||||
|
||||
$end = self::resolveEnd($vevent, $start, $isAllDay);
|
||||
|
||||
$event = new CalendarEvent();
|
||||
$event->slotKey = $slotKey;
|
||||
$event->uid = trim((string)($vevent->UID ?? ''));
|
||||
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
|
||||
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||
$event->startIso = $start->format(DateTimeInterface::ATOM);
|
||||
$event->endIso = $end->format(DateTimeInterface::ATOM);
|
||||
$event->allDay = $isAllDay;
|
||||
$event->time = $isAllDay ? '' : $start->format('H:i');
|
||||
$event->location = trim((string)($vevent->LOCATION ?? ''));
|
||||
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
|
||||
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
|
||||
$event->componentType = 'VEVENT';
|
||||
$event->dateIso = $start->format('Y-m-d');
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VTODO for a specific day.
|
||||
*
|
||||
* @param VTodo $vtodo
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVTodoForDay(VTodo $vtodo, string $slotKey, string $dateIso): ?CalendarEvent
|
||||
{
|
||||
$event = self::normalizeVTodo($vtodo, $slotKey);
|
||||
if ($event === null) return null;
|
||||
|
||||
// Check if the VTODO's due/start date matches this day
|
||||
if ($event->dateIso !== $dateIso) return null;
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VTODO into a CalendarEvent.
|
||||
*
|
||||
* @param VTodo $vtodo
|
||||
* @param string $slotKey
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVTodo(VTodo $vtodo, string $slotKey): ?CalendarEvent
|
||||
{
|
||||
// VTODO uses DUE or DTSTART for date
|
||||
$dateProperty = $vtodo->DUE ?? $vtodo->DTSTART ?? null;
|
||||
if ($dateProperty === null) return null;
|
||||
|
||||
$isAllDay = strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
|
||||
$dt = self::toImmutable($dateProperty->getDateTime());
|
||||
if ($dt === null) return null;
|
||||
|
||||
$event = new CalendarEvent();
|
||||
$event->slotKey = $slotKey;
|
||||
$event->uid = trim((string)($vtodo->UID ?? ''));
|
||||
$event->recurrenceId = isset($vtodo->{'RECURRENCE-ID'}) ? trim((string)$vtodo->{'RECURRENCE-ID'}) : '';
|
||||
$event->summary = trim((string)($vtodo->SUMMARY ?? ''));
|
||||
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||
$event->startIso = $dt->format(DateTimeInterface::ATOM);
|
||||
$event->endIso = '';
|
||||
$event->allDay = $isAllDay;
|
||||
$event->time = $isAllDay ? '' : $dt->format('H:i');
|
||||
$event->location = trim((string)($vtodo->LOCATION ?? ''));
|
||||
$event->description = trim((string)($vtodo->DESCRIPTION ?? ''));
|
||||
|
||||
$status = strtoupper(trim((string)($vtodo->STATUS ?? '')));
|
||||
// Map VTODO statuses to our model
|
||||
if ($status === 'COMPLETED') {
|
||||
$event->status = 'COMPLETED';
|
||||
} elseif ($status === 'IN-PROCESS' || $status === 'NEEDS-ACTION' || $status === '') {
|
||||
$event->status = 'TODO';
|
||||
} else {
|
||||
$event->status = $status;
|
||||
}
|
||||
|
||||
$event->componentType = 'VTODO';
|
||||
$event->dateIso = $dt->format('Y-m-d');
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the end date/time for a VEVENT.
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param DateTimeImmutable $start
|
||||
* @param bool $isAllDay
|
||||
* @return DateTimeImmutable
|
||||
*/
|
||||
protected static function resolveEnd(VEvent $vevent, DateTimeImmutable $start, bool $isAllDay): DateTimeImmutable
|
||||
{
|
||||
if (isset($vevent->DTEND)) {
|
||||
$end = self::toImmutable($vevent->DTEND->getDateTime());
|
||||
if ($end !== null) return $end;
|
||||
}
|
||||
|
||||
if (isset($vevent->DURATION)) {
|
||||
try {
|
||||
$duration = $vevent->DURATION->getDateInterval();
|
||||
if ($duration instanceof DateInterval) {
|
||||
return $start->add($duration);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date range intersects a given day.
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @param bool $isAllDay
|
||||
* @param string $dateIso
|
||||
* @return bool
|
||||
*/
|
||||
protected static function intersectsDay(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
bool $isAllDay,
|
||||
string $dateIso
|
||||
): bool {
|
||||
$eventTimezone = $start->getTimezone();
|
||||
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
|
||||
$dayEnd = $dayStart->add(new DateInterval('P1D'));
|
||||
|
||||
if ($end <= $start) {
|
||||
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||
}
|
||||
|
||||
$intersects = ($start < $dayEnd) && ($end > $dayStart);
|
||||
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
|
||||
$intersects = true;
|
||||
}
|
||||
|
||||
return $intersects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two CalendarEvents for sorting.
|
||||
*
|
||||
* @param CalendarEvent $a
|
||||
* @param CalendarEvent $b
|
||||
* @return int
|
||||
*/
|
||||
protected static function compareEvents(CalendarEvent $a, CalendarEvent $b): int
|
||||
{
|
||||
if ($a->allDay !== $b->allDay) {
|
||||
return $a->allDay ? -1 : 1;
|
||||
}
|
||||
$timeCmp = strcmp($a->time, $b->time);
|
||||
if ($timeCmp !== 0) return $timeCmp;
|
||||
return strcmp($a->summary, $b->summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DateTimeInterface to DateTimeImmutable.
|
||||
*
|
||||
* @param DateTimeInterface $dt
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
protected static function toImmutable(DateTimeInterface $dt): ?DateTimeImmutable
|
||||
{
|
||||
if ($dt instanceof DateTimeImmutable) return $dt;
|
||||
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dt->getTimestamp());
|
||||
if (!($immutable instanceof DateTimeImmutable)) return null;
|
||||
return $immutable->setTimezone($dt->getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all runtime caches.
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$dayCache = [];
|
||||
self::$taskCache = [];
|
||||
}
|
||||
}
|
||||
171
src/CalendarSlot.php
Normal file
171
src/CalendarSlot.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
/**
|
||||
* Represents one calendar slot configuration.
|
||||
*
|
||||
* Each slot has a stable key, a human-readable label, local/remote source
|
||||
* configuration, a display color, and a derived enabled state.
|
||||
*/
|
||||
class CalendarSlot
|
||||
{
|
||||
/** @var string[] Ordered list of all supported slot keys */
|
||||
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4'];
|
||||
|
||||
/** @var array<string,string> Human-readable labels for slot keys */
|
||||
public const SLOT_LABELS = [
|
||||
'general' => 'General',
|
||||
'maintenance' => 'Maintenance',
|
||||
'slot3' => 'Slot 3',
|
||||
'slot4' => 'Slot 4',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
protected $key;
|
||||
|
||||
/** @var string */
|
||||
protected $label;
|
||||
|
||||
/** @var string Local ICS file path */
|
||||
protected $file;
|
||||
|
||||
/** @var string CalDAV URL */
|
||||
protected $caldavUrl;
|
||||
|
||||
/** @var string CalDAV username */
|
||||
protected $username;
|
||||
|
||||
/** @var string CalDAV password */
|
||||
protected $password;
|
||||
|
||||
/** @var string CSS color for widget indicators */
|
||||
protected $color;
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param string $file
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $color
|
||||
*/
|
||||
public function __construct(
|
||||
string $key,
|
||||
string $file = '',
|
||||
string $caldavUrl = '',
|
||||
string $username = '',
|
||||
string $password = '',
|
||||
string $color = ''
|
||||
) {
|
||||
$this->key = $key;
|
||||
$this->label = self::SLOT_LABELS[$key] ?? $key;
|
||||
$this->file = trim($file);
|
||||
$this->caldavUrl = trim($caldavUrl);
|
||||
$this->username = trim($username);
|
||||
$this->password = trim($password);
|
||||
$this->color = trim($color);
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getFile(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
public function getCaldavUrl(): string
|
||||
{
|
||||
return $this->caldavUrl;
|
||||
}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot is enabled if it has a local file path or a CalDAV URL.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->file !== '' || $this->caldavUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this slot has a usable local ICS file.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasLocalFile(): bool
|
||||
{
|
||||
if ($this->file === '') return false;
|
||||
return is_file($this->file) && is_readable($this->file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this slot has a configured remote CalDAV source.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRemoteSource(): bool
|
||||
{
|
||||
return $this->caldavUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all configured calendar slots from plugin configuration.
|
||||
*
|
||||
* @param object $plugin Plugin instance with getConf() method
|
||||
* @return CalendarSlot[] Keyed by slot key
|
||||
*/
|
||||
public static function loadAll($plugin): array
|
||||
{
|
||||
$slots = [];
|
||||
foreach (self::SLOT_KEYS as $key) {
|
||||
$slots[$key] = new self(
|
||||
$key,
|
||||
(string)$plugin->getConf('calendar_' . $key . '_file'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_username'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_password'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_color')
|
||||
);
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only enabled calendar slots.
|
||||
*
|
||||
* @param object $plugin Plugin instance with getConf() method
|
||||
* @return CalendarSlot[] Keyed by slot key
|
||||
*/
|
||||
public static function loadEnabled($plugin): array
|
||||
{
|
||||
$all = self::loadAll($plugin);
|
||||
return array_filter($all, static function (CalendarSlot $slot): bool {
|
||||
return $slot->isEnabled();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,17 @@ class ChronologicalCalendarWidget
|
||||
* @param int $year
|
||||
* @param int $month
|
||||
* @param string $baseNs
|
||||
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
|
||||
* @param array<string,string> $slotColors slotKey => CSS color
|
||||
* @return string
|
||||
*/
|
||||
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
|
||||
{
|
||||
public static function render(
|
||||
int $year,
|
||||
int $month,
|
||||
string $baseNs = 'chronological',
|
||||
array $indicators = [],
|
||||
array $slotColors = []
|
||||
): string {
|
||||
if (!self::isValidMonth($year, $month)) return '';
|
||||
|
||||
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
|
||||
@@ -109,6 +116,19 @@ class ChronologicalCalendarWidget
|
||||
$classes = 'luxtools-calendar-day';
|
||||
|
||||
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
|
||||
|
||||
// Render slot indicators if any
|
||||
$dayIndicators = $indicators[$date] ?? [];
|
||||
if ($dayIndicators !== []) {
|
||||
$html .= '<div class="luxtools-calendar-indicators">';
|
||||
foreach ($dayIndicators as $slotKey) {
|
||||
$color = $slotColors[$slotKey] ?? '';
|
||||
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
|
||||
$html .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($slotKey) . '"' . $style . '></span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
if ($dayId !== null && function_exists('html_wikilink')) {
|
||||
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
|
||||
} else {
|
||||
|
||||
434
src/IcsWriter.php
Normal file
434
src/IcsWriter.php
Normal file
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Component\VEvent;
|
||||
use Sabre\VObject\Component\VTodo;
|
||||
use Sabre\VObject\Reader;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Write-back support for local ICS files.
|
||||
*
|
||||
* Handles updating event/task status (completion, reopening) in local
|
||||
* ICS files while preserving the original component type and other properties.
|
||||
*/
|
||||
class IcsWriter
|
||||
{
|
||||
/**
|
||||
* Update the STATUS of an event or task occurrence in a local ICS file.
|
||||
*
|
||||
* For VEVENT: sets STATUS to the given value (TODO or COMPLETED).
|
||||
* For VTODO: sets STATUS and, when completing, sets COMPLETED timestamp;
|
||||
* when reopening, removes the COMPLETED property.
|
||||
*
|
||||
* For recurring events, this writes an override/exception for the specific
|
||||
* occurrence rather than modifying the master event.
|
||||
*
|
||||
* @param string $filePath Absolute path to the local ICS file
|
||||
* @param string $uid Event UID
|
||||
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
|
||||
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
|
||||
* @param string $dateIso Occurrence date YYYY-MM-DD (for recurring event identification)
|
||||
* @return bool True if the file was updated successfully
|
||||
*/
|
||||
public static function updateEventStatus(
|
||||
string $filePath,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
if ($uid === '' || $filePath === '') return false;
|
||||
if (!is_file($filePath) || !is_writable($filePath)) return false;
|
||||
|
||||
$raw = @file_get_contents($filePath);
|
||||
if (!is_string($raw) || trim($raw) === '') return false;
|
||||
|
||||
try {
|
||||
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||
if (!($calendar instanceof VCalendar)) return false;
|
||||
|
||||
$updated = self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
|
||||
if (!$updated) return false;
|
||||
|
||||
$output = $calendar->serialize();
|
||||
return self::atomicWrite($filePath, $output);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a status update to the matching component in the calendar.
|
||||
*
|
||||
* Public alias for use by CalDavClient when modifying remote calendar data.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param string $uid
|
||||
* @param string $recurrenceId
|
||||
* @param string $newStatus
|
||||
* @param string $dateIso
|
||||
* @return bool True if a component was updated
|
||||
*/
|
||||
public static function applyStatusUpdateToCalendar(
|
||||
VCalendar $calendar,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
return self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a status update to the matching component in the calendar.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param string $uid
|
||||
* @param string $recurrenceId
|
||||
* @param string $newStatus
|
||||
* @param string $dateIso
|
||||
* @return bool True if a component was updated
|
||||
*/
|
||||
protected static function applyStatusUpdate(
|
||||
VCalendar $calendar,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
// Try VEVENT first
|
||||
foreach ($calendar->select('VEVENT') as $component) {
|
||||
if (!($component instanceof VEvent)) continue;
|
||||
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
|
||||
self::setVEventStatus($component, $newStatus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try VTODO
|
||||
foreach ($calendar->select('VTODO') as $component) {
|
||||
if (!($component instanceof VTodo)) continue;
|
||||
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
|
||||
self::setVTodoStatus($component, $newStatus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For recurring events without a matching override, create one
|
||||
foreach ($calendar->select('VEVENT') as $component) {
|
||||
if (!($component instanceof VEvent)) continue;
|
||||
$componentUid = trim((string)($component->UID ?? ''));
|
||||
if ($componentUid !== $uid) continue;
|
||||
|
||||
// This is the master event; check if it has RRULE (recurring)
|
||||
if (!isset($component->RRULE)) continue;
|
||||
|
||||
// Create an occurrence override
|
||||
$override = self::createOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
|
||||
if ($override !== null) return true;
|
||||
}
|
||||
|
||||
// Same for VTODO with RRULE
|
||||
foreach ($calendar->select('VTODO') as $component) {
|
||||
if (!($component instanceof VTodo)) continue;
|
||||
$componentUid = trim((string)($component->UID ?? ''));
|
||||
if ($componentUid !== $uid) continue;
|
||||
|
||||
if (!isset($component->RRULE)) continue;
|
||||
|
||||
$override = self::createVTodoOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
|
||||
if ($override !== null) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a component matches the given UID and recurrence criteria.
|
||||
*
|
||||
* @param VEvent|VTodo $component
|
||||
* @param string $uid
|
||||
* @param string $recurrenceId
|
||||
* @param string $dateIso
|
||||
* @return bool
|
||||
*/
|
||||
protected static function matchesComponent($component, string $uid, string $recurrenceId, string $dateIso): bool
|
||||
{
|
||||
$componentUid = trim((string)($component->UID ?? ''));
|
||||
if ($componentUid !== $uid) return false;
|
||||
|
||||
// If we have a specific recurrence ID, match it
|
||||
if ($recurrenceId !== '') {
|
||||
$componentRid = isset($component->{'RECURRENCE-ID'}) ? trim((string)$component->{'RECURRENCE-ID'}) : '';
|
||||
return $componentRid === $recurrenceId;
|
||||
}
|
||||
|
||||
// For non-recurring events (no RRULE, no RECURRENCE-ID), match by UID alone
|
||||
if (!isset($component->RRULE) && !isset($component->{'RECURRENCE-ID'})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For a specific occurrence override already in the file
|
||||
if (isset($component->{'RECURRENCE-ID'})) {
|
||||
$ridDt = $component->{'RECURRENCE-ID'}->getDateTime();
|
||||
if ($ridDt !== null && $ridDt->format('Y-m-d') === $dateIso) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the STATUS property on a VEVENT.
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param string $newStatus
|
||||
*/
|
||||
protected static function setVEventStatus(VEvent $vevent, string $newStatus): void
|
||||
{
|
||||
$vevent->STATUS = $newStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the STATUS property on a VTODO with native completion semantics.
|
||||
*
|
||||
* @param VTodo $vtodo
|
||||
* @param string $newStatus
|
||||
*/
|
||||
protected static function setVTodoStatus(VTodo $vtodo, string $newStatus): void
|
||||
{
|
||||
if ($newStatus === 'COMPLETED') {
|
||||
$vtodo->STATUS = 'COMPLETED';
|
||||
// Set COMPLETED timestamp per RFC 5545
|
||||
$vtodo->COMPLETED = gmdate('Ymd\THis\Z');
|
||||
// Set PERCENT-COMPLETE to 100
|
||||
$vtodo->{'PERCENT-COMPLETE'} = '100';
|
||||
} else {
|
||||
// Reopening
|
||||
$vtodo->STATUS = 'NEEDS-ACTION';
|
||||
// Remove COMPLETED timestamp
|
||||
if (isset($vtodo->COMPLETED)) {
|
||||
unset($vtodo->COMPLETED);
|
||||
}
|
||||
if (isset($vtodo->{'PERCENT-COMPLETE'})) {
|
||||
unset($vtodo->{'PERCENT-COMPLETE'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an occurrence override for a recurring VEVENT.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param VEvent $master
|
||||
* @param string $newStatus
|
||||
* @param string $dateIso
|
||||
* @return VEvent|null
|
||||
*/
|
||||
protected static function createOccurrenceOverride(
|
||||
VCalendar $calendar,
|
||||
VEvent $master,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): ?VEvent {
|
||||
try {
|
||||
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||
|
||||
$props = [
|
||||
'UID' => (string)$master->UID,
|
||||
'SUMMARY' => (string)($master->SUMMARY ?? ''),
|
||||
'STATUS' => $newStatus,
|
||||
];
|
||||
|
||||
if ($isAllDay) {
|
||||
$recurrenceValue = str_replace('-', '', $dateIso);
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
// Set VALUE=DATE on the properties
|
||||
$override = $calendar->add('VEVENT', $props);
|
||||
$override->DTSTART['VALUE'] = 'DATE';
|
||||
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
|
||||
} else {
|
||||
// Use the master's time for the occurrence
|
||||
$masterStart = $master->DTSTART->getDateTime();
|
||||
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
|
||||
$tz = $masterStart->getTimezone();
|
||||
if ($tz && $tz->getName() !== 'UTC') {
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
$override = $calendar->add('VEVENT', $props);
|
||||
$override->DTSTART['TZID'] = $tz->getName();
|
||||
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
|
||||
} else {
|
||||
$recurrenceValue .= 'Z';
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
$override = $calendar->add('VEVENT', $props);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy duration or DTEND if present
|
||||
if (isset($master->DTEND)) {
|
||||
$duration = $master->DTSTART->getDateTime()->diff($master->DTEND->getDateTime());
|
||||
$startDt = $override->DTSTART->getDateTime();
|
||||
$endDt = $startDt->add($duration);
|
||||
if ($isAllDay) {
|
||||
$override->add('DTEND', $endDt->format('Ymd'));
|
||||
$override->DTEND['VALUE'] = 'DATE';
|
||||
} else {
|
||||
$override->add('DTEND', $endDt);
|
||||
}
|
||||
} elseif (isset($master->DURATION)) {
|
||||
$override->DURATION = (string)$master->DURATION;
|
||||
}
|
||||
|
||||
// Copy LOCATION and DESCRIPTION if present
|
||||
if (isset($master->LOCATION)) {
|
||||
$override->LOCATION = (string)$master->LOCATION;
|
||||
}
|
||||
if (isset($master->DESCRIPTION)) {
|
||||
$override->DESCRIPTION = (string)$master->DESCRIPTION;
|
||||
}
|
||||
|
||||
return $override;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an occurrence override for a recurring VTODO.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param VTodo $master
|
||||
* @param string $newStatus
|
||||
* @param string $dateIso
|
||||
* @return VTodo|null
|
||||
*/
|
||||
protected static function createVTodoOccurrenceOverride(
|
||||
VCalendar $calendar,
|
||||
VTodo $master,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): ?VTodo {
|
||||
try {
|
||||
$dateProperty = $master->DUE ?? $master->DTSTART ?? null;
|
||||
$isAllDay = $dateProperty !== null && strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
|
||||
|
||||
$props = [
|
||||
'UID' => (string)$master->UID,
|
||||
'SUMMARY' => (string)($master->SUMMARY ?? ''),
|
||||
];
|
||||
|
||||
if ($isAllDay) {
|
||||
$recurrenceValue = str_replace('-', '', $dateIso);
|
||||
if (isset($master->DTSTART)) {
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
}
|
||||
if (isset($master->DUE)) {
|
||||
$props['DUE'] = $recurrenceValue;
|
||||
}
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
|
||||
$override = $calendar->add('VTODO', $props);
|
||||
|
||||
if (isset($override->DTSTART)) {
|
||||
$override->DTSTART['VALUE'] = 'DATE';
|
||||
}
|
||||
if (isset($override->DUE)) {
|
||||
$override->DUE['VALUE'] = 'DATE';
|
||||
}
|
||||
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
|
||||
} else {
|
||||
$dt = $dateProperty->getDateTime();
|
||||
$recurrenceValue = $dateIso . 'T' . $dt->format('His');
|
||||
$tz = $dt->getTimezone();
|
||||
if ($tz && $tz->getName() !== 'UTC') {
|
||||
if (isset($master->DTSTART)) {
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
}
|
||||
if (isset($master->DUE)) {
|
||||
$props['DUE'] = $recurrenceValue;
|
||||
}
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
$override = $calendar->add('VTODO', $props);
|
||||
if (isset($override->DTSTART)) {
|
||||
$override->DTSTART['TZID'] = $tz->getName();
|
||||
}
|
||||
if (isset($override->DUE)) {
|
||||
$override->DUE['TZID'] = $tz->getName();
|
||||
}
|
||||
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
|
||||
} else {
|
||||
$recurrenceValue .= 'Z';
|
||||
if (isset($master->DTSTART)) {
|
||||
$props['DTSTART'] = $recurrenceValue;
|
||||
}
|
||||
if (isset($master->DUE)) {
|
||||
$props['DUE'] = $recurrenceValue;
|
||||
}
|
||||
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||
$override = $calendar->add('VTODO', $props);
|
||||
}
|
||||
}
|
||||
|
||||
self::setVTodoStatus($override, $newStatus);
|
||||
|
||||
if (isset($master->LOCATION)) {
|
||||
$override->LOCATION = (string)$master->LOCATION;
|
||||
}
|
||||
if (isset($master->DESCRIPTION)) {
|
||||
$override->DESCRIPTION = (string)$master->DESCRIPTION;
|
||||
}
|
||||
|
||||
return $override;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public atomic file write for use by CalDavClient sync.
|
||||
*
|
||||
* @param string $filePath
|
||||
* @param string $content
|
||||
* @return bool
|
||||
*/
|
||||
public static function atomicWritePublic(string $filePath, string $content): bool
|
||||
{
|
||||
$dir = dirname($filePath);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return self::atomicWrite($filePath, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic file write using a temp file and rename.
|
||||
*
|
||||
* @param string $filePath
|
||||
* @param string $content
|
||||
* @return bool
|
||||
*/
|
||||
protected static function atomicWrite(string $filePath, string $content): bool
|
||||
{
|
||||
$dir = dirname($filePath);
|
||||
$tmpFile = $dir . '/.luxtools_tmp_' . getmypid() . '_' . mt_rand();
|
||||
|
||||
if (@file_put_contents($tmpFile, $content, LOCK_EX) === false) {
|
||||
@unlink($tmpFile);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!@rename($tmpFile, $filePath)) {
|
||||
@unlink($tmpFile);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
238
style.css
238
style.css
@@ -646,3 +646,241 @@ div.luxtools-calendar td.luxtools-calendar-day:hover {
|
||||
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
|
||||
background-color: @ini_highlight;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Calendar Widget Indicators
|
||||
* Colored corner markers showing which slots have events on a day.
|
||||
* Positions: general=top-left, maintenance=top-right,
|
||||
* slot3=bottom-right, slot4=bottom-left (clockwise)
|
||||
* ============================================================ */
|
||||
div.luxtools-calendar td.luxtools-calendar-day {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.luxtools-calendar-indicators {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.luxtools-calendar-indicator {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.luxtools-indicator-general {
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.luxtools-indicator-maintenance {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.luxtools-indicator-slot3 {
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.luxtools-indicator-slot4 {
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Chronological Events on Day Pages
|
||||
* ============================================================ */
|
||||
div.luxtools-chronological-events ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
div.luxtools-chronological-events li {
|
||||
padding: 0.35em 0.5em;
|
||||
margin: 0.25em 0;
|
||||
border-left: 3px solid @ini_border;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.luxtools-chronological-events li:hover {
|
||||
background-color: @ini_background_alt;
|
||||
}
|
||||
|
||||
div.luxtools-chronological-events li[data-luxtools-event] .luxtools-event-time {
|
||||
font-weight: bold;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Maintenance Tasks
|
||||
* ============================================================ */
|
||||
div.luxtools-chronological-maintenance li {
|
||||
border-left-color: #e67e22;
|
||||
}
|
||||
|
||||
li.luxtools-maintenance-task.luxtools-task-completed {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
button.luxtools-task-action,
|
||||
button.luxtools-task-complete-btn {
|
||||
margin-left: 0.5em;
|
||||
padding: 0.15em 0.5em;
|
||||
font-size: 0.85em;
|
||||
border: 1px solid @ini_border;
|
||||
border-radius: 0.2em;
|
||||
background-color: @ini_background_alt;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.luxtools-task-action:hover,
|
||||
button.luxtools-task-complete-btn:hover {
|
||||
background-color: @ini_highlight;
|
||||
}
|
||||
|
||||
button.luxtools-task-action:disabled,
|
||||
button.luxtools-task-complete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Maintenance Task List (syntax plugin)
|
||||
* ============================================================ */
|
||||
div.luxtools-maintenance-tasks {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
ul.luxtools-maintenance-task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul.luxtools-maintenance-task-list li {
|
||||
padding: 0.35em 0.5em;
|
||||
margin: 0.25em 0;
|
||||
border-left: 3px solid #e67e22;
|
||||
}
|
||||
|
||||
li.luxtools-task-overdue .luxtools-task-date {
|
||||
color: #c0392b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.luxtools-task-date {
|
||||
font-family: monospace;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.luxtools-task-time {
|
||||
font-weight: bold;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.luxtools-maintenance-task-item.luxtools-task-completed {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Event Popup
|
||||
* ============================================================ */
|
||||
.luxtools-event-popup-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.luxtools-event-popup {
|
||||
background: @ini_background;
|
||||
border: 1px solid @ini_border;
|
||||
border-radius: 0.4em;
|
||||
padding: 1.5em;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.luxtools-event-popup-close {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.75em;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: @ini_text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.luxtools-event-popup-close:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.luxtools-event-popup-title {
|
||||
margin: 0 0 0.75em 0;
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
|
||||
.luxtools-event-popup-field {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.luxtools-event-popup-description {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.luxtools-event-popup-slot {
|
||||
margin-top: 1em;
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Notifications (fallback)
|
||||
* ============================================================ */
|
||||
.luxtools-notification {
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
z-index: 10001;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.9em;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.luxtools-notification-error {
|
||||
background: #c0392b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.luxtools-notification-warning {
|
||||
background: #e67e22;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use dokuwiki\Extension\SyntaxPlugin;
|
||||
use dokuwiki\plugin\luxtools\CalendarService;
|
||||
use dokuwiki\plugin\luxtools\CalendarSlot;
|
||||
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
||||
|
||||
require_once(__DIR__ . '/../autoload.php');
|
||||
@@ -85,7 +87,18 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
|
||||
$month = (int)$data['month'];
|
||||
$baseNs = (string)$data['base'];
|
||||
|
||||
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs);
|
||||
// Load slot indicators and colors for the calendar widget
|
||||
$slots = CalendarSlot::loadEnabled($this);
|
||||
$indicators = CalendarService::monthIndicators($slots, $year, $month);
|
||||
$slotColors = [];
|
||||
foreach ($slots as $slot) {
|
||||
$color = $slot->getColor();
|
||||
if ($color !== '') {
|
||||
$slotColors[$slot->getKey()] = $color;
|
||||
}
|
||||
}
|
||||
|
||||
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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