Calendar Sync V1

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

View File

@@ -4,11 +4,15 @@ use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\Extension\EventHandler;
use dokuwiki\plugin\luxtools\CacheInvalidation;
use dokuwiki\plugin\luxtools\CalDavClient;
use dokuwiki\plugin\luxtools\CalendarEvent;
use dokuwiki\plugin\luxtools\CalendarService;
use dokuwiki\plugin\luxtools\CalendarSlot;
use dokuwiki\plugin\luxtools\ChronoID;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
use dokuwiki\plugin\luxtools\IcsWriter;
use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache;
require_once(__DIR__ . '/autoload.php');
@@ -65,6 +69,18 @@ class action_plugin_luxtools extends ActionPlugin
$this,
"handleCalendarWidgetAjax",
);
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleMaintenanceTaskAction",
);
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleCalendarSyncAction",
);
$controller->register_hook(
"ACTION_ACT_PREPROCESS",
"BEFORE",
@@ -105,6 +121,7 @@ class action_plugin_luxtools extends ActionPlugin
"page-link.js",
"linkfavicon.js",
"calendar-widget.js",
"event-popup.js",
"main.js",
];
@@ -147,7 +164,18 @@ class action_plugin_luxtools extends ActionPlugin
$this->sendNoStoreHeaders();
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs);
// Load slot indicators and colors for the calendar widget
$slots = CalendarSlot::loadEnabled($this);
$indicators = CalendarService::monthIndicators($slots, $year, $month);
$slotColors = [];
foreach ($slots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
}
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
if ($html === '') {
http_status(500);
echo 'Calendar rendering failed';
@@ -458,50 +486,348 @@ class action_plugin_luxtools extends ActionPlugin
/**
* Render local calendar events section for a given date.
*
* Uses the slot-aware CalendarService to render events from all enabled slots.
*
* @param string $dateIso
* @return string
*/
protected function renderChronologicalEventsHtml(string $dateIso): string
{
$icsConfig = (string)$this->getConf('calendar_ics_files');
if (trim($icsConfig) === '') return '';
$slots = CalendarSlot::loadEnabled($this);
if ($slots === []) return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
if ($events === []) return '';
$grouped = CalendarService::eventsForDateGrouped($slots, $dateIso);
if ($grouped === []) return '';
$title = (string)$this->getLang('chronological_events_title');
if ($title === '') $title = 'Events';
$html = '';
$items = '';
foreach ($events as $entry) {
$summary = trim((string)($entry['summary'] ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
// Render general events
if (isset($grouped['general'])) {
$title = (string)$this->getLang('chronological_events_title');
if ($title === '') $title = 'Events';
$html .= $this->renderEventSection($grouped['general'], $title, 'general');
}
$time = trim((string)($entry['time'] ?? ''));
$startIso = trim((string)($entry['startIso'] ?? ''));
$isAllDay = (bool)($entry['allDay'] ?? false);
// Render maintenance tasks
if (isset($grouped['maintenance'])) {
$title = (string)$this->getLang('chronological_maintenance_title');
if ($title === '') $title = 'Tasks';
$html .= $this->renderMaintenanceSection($grouped['maintenance'], $title, $dateIso);
}
if ($isAllDay || $time === '') {
$items .= '<li>' . hsc($summary) . '</li>';
} else {
$timeHtml = '<span class="luxtools-event-time"';
if ($startIso !== '') {
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
}
$timeHtml .= '>' . hsc($time) . '</span>';
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
// Render slot3/slot4 if present
foreach (['slot3', 'slot4'] as $slotKey) {
if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
$label = $slots[$slotKey]->getLabel();
$html .= $this->renderEventSection($grouped[$slotKey], $label, $slotKey);
}
}
if ($items === '') return '';
$html = '<ul>' . $items . '</ul>';
return $html;
}
return '<div class="luxtools-plugin luxtools-chronological-events">'
/**
* Render a section of events for a given slot.
*
* @param CalendarEvent[] $events
* @param string $title
* @param string $slotKey
* @return string
*/
protected function renderEventSection(array $events, string $title, string $slotKey): string
{
$items = '';
foreach ($events as $event) {
$items .= $this->renderEventListItem($event);
}
if ($items === '') return '';
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-slot-' . hsc($slotKey) . '">'
. '<h2>' . hsc($title) . '</h2>'
. $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;
}
}