diff --git a/README.md b/README.md
index 4fecaad..6805591 100644
--- a/README.md
+++ b/README.md
@@ -447,8 +447,117 @@ 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.
+Sync can also be triggered from any wiki page using the inline syntax:
+
+```
+{{calendar_sync>}}
+```
+
+The sync button is only visible to admins. Non-admin users see nothing.
+
+Sync is admin-only and does not run automatically. For scheduled sync, see the
+automatic sync section below.
+
+### 0.12) Event popup improvements
+
+Clicking an event on a day page or in the calendar widget opens the event detail popup.
+When the popup is opened from a specific day context (e.g. clicking an event in a calendar
+day cell), the date is hidden and only the time is shown (since the day is already known).
+
+Clicking empty space inside a calendar day cell opens a day popup listing all events for
+that day. If there are no events, a "No events" message is shown.
+
+### 0.13) Event creation
+
+Authenticated users can create new calendar events from the day popup.
+
+1. Click empty space in a calendar day cell to open the day popup.
+2. Click "Create Event".
+3. Fill in the form: summary (required), date, all-day toggle, start/end time,
+ location, description, and target calendar slot.
+4. Click "Save".
+
+The event is written to the local `.ics` file immediately. If the slot has a
+CalDAV URL configured, the event is also uploaded to the remote server.
+
+### 0.14) Event editing
+
+Authenticated users can edit existing calendar events from the event popup.
+
+1. Click an event to open the detail popup.
+2. Click "Edit".
+3. Modify the fields and click "Save".
+
+For recurring events, editing creates or updates an occurrence override rather
+than modifying the master event.
+
+Changes are written to the local `.ics` file first, then pushed to CalDAV if
+configured.
+
+### 0.15) Event deletion
+
+Authenticated users can delete events from the event popup.
+
+1. Click an event to open the detail popup.
+2. Click "Delete".
+3. Confirm the deletion.
+
+For recurring events, you are asked whether to delete:
+- **This occurrence**: Adds an EXDATE to the master event.
+- **This and future occurrences**: Sets an UNTIL date on the RRULE.
+- **All occurrences**: Removes all components with this UID.
+
+Deletion is applied to the local `.ics` file first, then to CalDAV if configured.
+
+### 0.16) Automatic sync proposal
+
+Automatic calendar sync is not currently implemented. The recommended approach
+for a long-lived personal DokuWiki plugin:
+
+**Option A: Cron calling the AJAX endpoint (simplest)**
+
+```cron
+*/15 * * * * curl -s -X POST 'https://wiki.example.com/lib/exe/ajax.php' \
+ -d 'call=luxtools_calendar_sync§ok=ADMIN_TOKEN' \
+ --cookie 'DokuWikiCookie=SESSION_ID'
+```
+
+Pros: Reuses the existing sync endpoint with no code changes.
+Cons: Requires a valid admin session cookie and security token, which expire and
+must be refreshed. Fragile for long-term unattended use.
+
+**Option B: CLI script (recommended)**
+
+Create `bin/calendar-sync.php` that boots DokuWiki from the command line,
+loads the plugin, and calls `CalendarSyncService::syncAll()` directly.
+
+```cron
+*/15 * * * * php /path/to/dokuwiki/lib/plugins/luxtools/bin/calendar-sync.php
+```
+
+Pros: No session management, no authentication needed, runs as the web server user.
+Cons: Requires a small CLI entry point script.
+
+**Recommendation**: Option B is more robust and maintainable. A CLI script can
+be as simple as:
+
+```php
+register_hook(
+ "AJAX_CALL_UNKNOWN",
+ "BEFORE",
+ $this,
+ "handleCalendarSlotsAction",
+ );
+ $controller->register_hook(
+ "AJAX_CALL_UNKNOWN",
+ "BEFORE",
+ $this,
+ "handleCalendarEventAction",
+ );
$controller->register_hook(
"ACTION_ACT_PREPROCESS",
"BEFORE",
@@ -642,6 +655,15 @@ class action_plugin_luxtools extends ActionPlugin
}
$dataAttrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
$dataAttrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
+ if ($event->uid !== '') {
+ $dataAttrs .= ' data-event-uid="' . hsc($event->uid) . '"';
+ }
+ if ($event->recurrenceId !== '') {
+ $dataAttrs .= ' data-event-recurrence="' . hsc($event->recurrenceId) . '"';
+ }
+ if ($event->dateIso !== '') {
+ $dataAttrs .= ' data-event-date="' . hsc($event->dateIso) . '"';
+ }
if ($event->allDay || $event->time === '') {
return '
' . $summaryHtml . ' ';
@@ -838,37 +860,440 @@ class action_plugin_luxtools extends ActionPlugin
return;
}
- if (!function_exists('auth_isadmin') || !auth_isadmin()) {
+ if (empty($_SERVER['REMOTE_USER'])) {
http_status(403);
- echo json_encode(['ok' => false, 'error' => 'Admin access required']);
+ echo json_encode(['ok' => false, 'error' => 'Authentication required']);
return;
}
$slots = CalendarSlot::loadEnabled($this);
- $results = [];
- $hasErrors = false;
+ $result = CalendarSyncService::syncAll($slots);
+ $msg = $result['ok']
+ ? $this->getLang('calendar_sync_success')
+ : $this->getLang('calendar_sync_partial');
+
+ echo json_encode([
+ 'ok' => $result['ok'],
+ 'message' => $msg,
+ 'results' => $result['results'],
+ ]);
+ }
+
+ /**
+ * Return available calendar slots as JSON for the event creation form.
+ *
+ * @param Event $event
+ * @param mixed $param
+ * @return void
+ */
+ public function handleCalendarSlotsAction(Event $event, $param)
+ {
+ if ($event->data !== 'luxtools_calendar_slots') return;
+
+ $event->preventDefault();
+ $event->stopPropagation();
+
+ header('Content-Type: application/json; charset=utf-8');
+ $this->sendNoStoreHeaders();
+
+ $slots = CalendarSlot::loadEnabled($this);
+ $result = [];
foreach ($slots as $slot) {
- if (!$slot->hasRemoteSource()) continue;
+ $result[] = [
+ 'key' => $slot->getKey(),
+ 'label' => $slot->getLabel(),
+ ];
+ }
- $ok = CalDavClient::syncSlot($slot);
- $results[$slot->getKey()] = $ok;
- if (!$ok) $hasErrors = true;
+ echo json_encode(['ok' => true, 'slots' => $result]);
+ }
+
+ /**
+ * Handle AJAX requests for creating, editing, and deleting calendar events.
+ *
+ * @param Event $event
+ * @param mixed $param
+ * @return void
+ */
+ public function handleCalendarEventAction(Event $event, $param)
+ {
+ if ($event->data !== 'luxtools_calendar_event') return;
+
+ $event->preventDefault();
+ $event->stopPropagation();
+
+ header('Content-Type: application/json; charset=utf-8');
+ $this->sendNoStoreHeaders();
+
+ global $INPUT;
+
+ // Require security token
+ if (!checkSecurityToken()) {
+ http_status(403);
+ echo json_encode(['ok' => false, 'error' => 'Security token mismatch']);
+ return;
+ }
+
+ // Require authenticated user
+ if (!isset($_SERVER['REMOTE_USER']) || $_SERVER['REMOTE_USER'] === '') {
+ http_status(403);
+ echo json_encode(['ok' => false, 'error' => 'Authentication required']);
+ return;
+ }
+
+ $action = $INPUT->str('action');
+ if (!in_array($action, ['create', 'edit', 'delete'], true)) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid action']);
+ return;
+ }
+
+ if ($action === 'create') {
+ $this->handleEventCreate($INPUT);
+ } elseif ($action === 'edit') {
+ $this->handleEventEdit($INPUT);
+ } elseif ($action === 'delete') {
+ $this->handleEventDelete($INPUT);
+ }
+ }
+
+ /**
+ * Handle event creation.
+ *
+ * @param \dokuwiki\Input\Input $INPUT
+ * @return void
+ */
+ protected function handleEventCreate($INPUT): void
+ {
+ $slotKey = $INPUT->str('slot');
+ $summary = trim($INPUT->str('summary'));
+ $dateIso = $INPUT->str('date');
+
+ if ($summary === '') {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Summary is required']);
+ return;
+ }
+ if (!ChronoID::isIsoDate($dateIso)) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid date']);
+ return;
+ }
+
+ $slots = CalendarSlot::loadAll($this);
+ $slot = $slots[$slotKey] ?? null;
+ if ($slot === null || !$slot->isEnabled()) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid calendar slot']);
+ return;
+ }
+
+ $file = $slot->getFile();
+ if ($file === '') {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'No local file configured for this slot']);
+ return;
+ }
+
+ $eventData = [
+ 'summary' => $summary,
+ 'date' => $dateIso,
+ 'allDay' => $INPUT->bool('allday'),
+ 'startTime' => $INPUT->str('start_time'),
+ 'endTime' => $INPUT->str('end_time'),
+ 'location' => trim($INPUT->str('location')),
+ 'description' => trim($INPUT->str('description')),
+ ];
+
+ $uid = IcsWriter::createEvent($file, $eventData);
+ if ($uid === '') {
+ http_status(500);
+ echo json_encode(['ok' => false, 'error' => 'Failed to create event']);
+ return;
}
CalendarService::clearCache();
- $msg = $hasErrors
- ? $this->getLang('calendar_sync_partial')
- : $this->getLang('calendar_sync_success');
+ // CalDAV write-back if configured
+ $remoteOk = true;
+ $remoteError = '';
+ if ($slot->hasRemoteSource()) {
+ $remoteOk = $this->pushEventToCalDav($slot, $file, $uid);
+ if (!$remoteOk) {
+ $remoteError = 'Local event created, but CalDAV upload failed.';
+ }
+ }
echo json_encode([
- 'ok' => !$hasErrors,
- 'message' => $msg,
- 'results' => $results,
+ 'ok' => true,
+ 'message' => 'Event created.',
+ 'uid' => $uid,
+ 'remoteOk' => $remoteOk,
+ 'remoteError' => $remoteError,
]);
}
+ /**
+ * Handle event editing.
+ *
+ * @param \dokuwiki\Input\Input $INPUT
+ * @return void
+ */
+ protected function handleEventEdit($INPUT): void
+ {
+ $uid = $INPUT->str('uid');
+ $recurrence = $INPUT->str('recurrence');
+ $slotKey = $INPUT->str('slot');
+ $summary = trim($INPUT->str('summary'));
+ $dateIso = $INPUT->str('date');
+ $scope = $INPUT->str('scope', 'all');
+
+ if (!in_array($scope, ['all', 'this', 'future'], true)) {
+ $scope = 'all';
+ }
+
+ if ($uid === '') {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Missing event UID']);
+ return;
+ }
+ if ($summary === '') {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Summary is required']);
+ return;
+ }
+ if (!ChronoID::isIsoDate($dateIso)) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid date']);
+ return;
+ }
+
+ $slots = CalendarSlot::loadAll($this);
+ $slot = $slots[$slotKey] ?? null;
+ if ($slot === null || !$slot->isEnabled()) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid calendar slot']);
+ return;
+ }
+
+ $file = $slot->getFile();
+ if ($file === '' || !is_file($file)) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'No local file for this slot']);
+ return;
+ }
+
+ $eventData = [
+ 'summary' => $summary,
+ 'date' => $dateIso,
+ 'allDay' => $INPUT->bool('allday'),
+ 'startTime' => $INPUT->str('start_time'),
+ 'endTime' => $INPUT->str('end_time'),
+ 'location' => trim($INPUT->str('location')),
+ 'description' => trim($INPUT->str('description')),
+ ];
+
+ $ok = IcsWriter::editEvent($file, $uid, $recurrence, $eventData, $scope);
+ if (!$ok) {
+ http_status(500);
+ echo json_encode(['ok' => false, 'error' => 'Failed to update event']);
+ return;
+ }
+
+ CalendarService::clearCache();
+
+ // CalDAV write-back if configured
+ $remoteOk = true;
+ $remoteError = '';
+ if ($slot->hasRemoteSource()) {
+ $remoteOk = $this->pushEventToCalDav($slot, $file, $uid);
+ if (!$remoteOk) {
+ $remoteError = 'Local event updated, but CalDAV upload failed.';
+ }
+ }
+
+ echo json_encode([
+ 'ok' => true,
+ 'message' => 'Event updated.',
+ 'remoteOk' => $remoteOk,
+ 'remoteError' => $remoteError,
+ ]);
+ }
+
+ /**
+ * Handle event deletion.
+ *
+ * @param \dokuwiki\Input\Input $INPUT
+ * @return void
+ */
+ protected function handleEventDelete($INPUT): void
+ {
+ $uid = $INPUT->str('uid');
+ $recurrence = $INPUT->str('recurrence');
+ $slotKey = $INPUT->str('slot');
+ $dateIso = $INPUT->str('date');
+ $scope = $INPUT->str('scope');
+
+ if ($uid === '') {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Missing event UID']);
+ return;
+ }
+ if (!in_array($scope, ['all', 'this', 'future'], true)) {
+ $scope = 'all';
+ }
+
+ $slots = CalendarSlot::loadAll($this);
+ $slot = $slots[$slotKey] ?? null;
+ if ($slot === null || !$slot->isEnabled()) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'Invalid calendar slot']);
+ return;
+ }
+
+ $file = $slot->getFile();
+ if ($file === '' || !is_file($file)) {
+ http_status(400);
+ echo json_encode(['ok' => false, 'error' => 'No local file for this slot']);
+ return;
+ }
+
+ $ok = IcsWriter::deleteEvent($file, $uid, $recurrence, $dateIso, $scope);
+ if (!$ok) {
+ http_status(500);
+ echo json_encode(['ok' => false, 'error' => 'Failed to delete event']);
+ return;
+ }
+
+ CalendarService::clearCache();
+
+ // CalDAV write-back: push updated file for this UID
+ $remoteOk = true;
+ $remoteError = '';
+ if ($slot->hasRemoteSource()) {
+ if ($scope === 'all') {
+ $remoteOk = $this->deleteEventFromCalDav($slot, $uid);
+ } else {
+ $remoteOk = $this->pushEventToCalDav($slot, $file, $uid);
+ }
+ if (!$remoteOk) {
+ $remoteError = 'Local event deleted, but CalDAV update failed.';
+ }
+ }
+
+ echo json_encode([
+ 'ok' => true,
+ 'message' => 'Event deleted.',
+ 'remoteOk' => $remoteOk,
+ 'remoteError' => $remoteError,
+ ]);
+ }
+
+ /**
+ * Push a single event to CalDAV by reading it from the local file
+ * and PUTting it to the server.
+ *
+ * @param CalendarSlot $slot
+ * @param string $file
+ * @param string $uid
+ * @return bool
+ */
+ protected function pushEventToCalDav(CalendarSlot $slot, string $file, string $uid): bool
+ {
+ try {
+ $raw = @file_get_contents($file);
+ if (!is_string($raw) || trim($raw) === '') return false;
+
+ $calendar = \Sabre\VObject\Reader::read($raw, \Sabre\VObject\Reader::OPTION_FORGIVING);
+ if (!($calendar instanceof \Sabre\VObject\Component\VCalendar)) return false;
+
+ // Extract just the components for this UID into a new calendar
+ $eventCal = new \Sabre\VObject\Component\VCalendar();
+ $eventCal->PRODID = '-//LuxTools DokuWiki Plugin//EN';
+ $found = false;
+
+ // Copy relevant VTIMEZONE
+ foreach ($calendar->select('VTIMEZONE') as $tz) {
+ $eventCal->add(clone $tz);
+ }
+
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (trim((string)($component->UID ?? '')) === $uid) {
+ $eventCal->add(clone $component);
+ $found = true;
+ }
+ }
+
+ if (!$found) return false;
+
+ $icsData = $eventCal->serialize();
+
+ // Find existing object on server or create new
+ $objectInfo = CalDavClient::findObjectByUidPublic(
+ $slot->getCaldavUrl(),
+ $slot->getUsername(),
+ $slot->getPassword(),
+ $uid
+ );
+
+ if ($objectInfo !== null) {
+ // Update existing object
+ $error = CalDavClient::putCalendarObjectPublic(
+ $objectInfo['href'],
+ $slot->getUsername(),
+ $slot->getPassword(),
+ $icsData,
+ $objectInfo['etag']
+ );
+ return $error === '';
+ }
+
+ // Create new object
+ $href = rtrim($slot->getCaldavUrl(), '/') . '/' . $uid . '.ics';
+ $error = CalDavClient::putCalendarObjectPublic(
+ $href,
+ $slot->getUsername(),
+ $slot->getPassword(),
+ $icsData,
+ ''
+ );
+ return $error === '';
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Delete an event from CalDAV by UID.
+ *
+ * @param CalendarSlot $slot
+ * @param string $uid
+ * @return bool
+ */
+ protected function deleteEventFromCalDav(CalendarSlot $slot, string $uid): bool
+ {
+ try {
+ $objectInfo = CalDavClient::findObjectByUidPublic(
+ $slot->getCaldavUrl(),
+ $slot->getUsername(),
+ $slot->getPassword(),
+ $uid
+ );
+
+ if ($objectInfo === null) return true; // Already gone
+
+ return CalDavClient::deleteCalendarObject(
+ $objectInfo['href'],
+ $slot->getUsername(),
+ $slot->getPassword(),
+ $objectInfo['etag']
+ );
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
/**
* Build wiki bullet list for local calendar events.
*
diff --git a/js/event-popup.js b/js/event-popup.js
index 6dfcfbe..2fa06aa 100644
--- a/js/event-popup.js
+++ b/js/event-popup.js
@@ -1,9 +1,12 @@
/* global window, document, jQuery */
/**
- * Event Popup and Maintenance Task Handling
+ * Event Popup, Day Popup, Event CRUD, and Maintenance Task Handling
*
* - Clicking an event item with data-luxtools-event="1" opens a detail popup.
+ * - Clicking empty space in a calendar day cell opens a day popup listing all events.
+ * - Day popup includes a "Create Event" action for authenticated users.
+ * - Event popup includes "Edit" and "Delete" actions for authenticated users.
* - Clicking a maintenance task action button sends an AJAX request to
* complete/reopen the task.
*/
@@ -12,10 +15,100 @@
var Luxtools = window.Luxtools || (window.Luxtools = {});
+ // Temporary storage for form data when showing the recurring edit scope dialog
+ var _pendingEditFormData = null;
+
// ============================================================
- // Event Popup
+ // Shared helpers
// ============================================================
- var EventPopup = (function () {
+ function escapeHtml(text) {
+ var div = document.createElement('div');
+ div.appendChild(document.createTextNode(text));
+ return div.innerHTML;
+ }
+
+ function pad2(value) {
+ return String(value).padStart(2, '0');
+ }
+
+ function formatDate(isoStr) {
+ if (!isoStr) return '';
+ var d = new Date(isoStr);
+ if (isNaN(d.getTime())) return isoStr;
+ return pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear();
+ }
+
+ function formatDateTime(isoStr) {
+ if (!isoStr) return '';
+ var d = new Date(isoStr);
+ if (isNaN(d.getTime())) return isoStr;
+ return formatDate(isoStr) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ }
+
+ function formatTimeOnly(isoStr) {
+ if (!isoStr) return '';
+ var d = new Date(isoStr);
+ if (isNaN(d.getTime())) return isoStr;
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ }
+
+ function isSameMoment(left, right) {
+ if (!left || !right) return false;
+ return left === right;
+ }
+
+ function getAjaxUrl() {
+ return (window.DOKU_BASE || '/') + 'lib/exe/ajax.php';
+ }
+
+ function getSecurityToken(el) {
+ // Try element hierarchy
+ if (el) {
+ var container = el.closest ? el.closest('[data-luxtools-sectok]') : null;
+ if (container) {
+ var tok = container.getAttribute('data-luxtools-sectok');
+ if (tok) return tok;
+ }
+ }
+ if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
+ var input = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
+ if (input && input.value) return String(input.value);
+ return '';
+ }
+
+ function isAuthenticated() {
+ // Check if user is logged in via JSINFO
+ if (window.JSINFO && window.JSINFO.isadmin) return true;
+ if (window.JSINFO && window.JSINFO.id !== undefined) {
+ // Check if user info exists (logged in users have a userinfo)
+ var userinfo = document.querySelector('.user');
+ if (userinfo) return true;
+ // Alternative: check for logout form
+ var logoutLink = document.querySelector('a[href*="do=logout"], .action.logout');
+ if (logoutLink) return true;
+ }
+ return false;
+ }
+
+ function showNotification(message, type) {
+ if (typeof window.msg === 'function') {
+ var level = (type === 'error') ? -1 : ((type === 'warning') ? 0 : 1);
+ window.msg(message, level);
+ return;
+ }
+ 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);
+ }
+
+ // ============================================================
+ // Popup infrastructure (shared overlay)
+ // ============================================================
+ var PopupUI = (function () {
var overlay = null;
var popup = null;
@@ -35,20 +128,49 @@
document.body.appendChild(overlay);
overlay.addEventListener('click', function (e) {
- if (e.target === overlay) {
- close();
- }
+ if (e.target === overlay) close();
});
document.addEventListener('keydown', function (e) {
- if (e.key === 'Escape' && overlay.style.display !== 'none') {
+ if (e.key === 'Escape' && overlay && overlay.style.display !== 'none') {
close();
}
});
}
- function open(el) {
+ function show(html) {
ensureElements();
+ popup.innerHTML = html;
+ overlay.style.display = 'flex';
+ var closeBtn = popup.querySelector('.luxtools-event-popup-close');
+ if (closeBtn) closeBtn.addEventListener('click', close);
+ }
+
+ function close() {
+ if (overlay) overlay.style.display = 'none';
+ }
+
+ function getPopup() {
+ ensureElements();
+ return popup;
+ }
+
+ return { show: show, close: close, getPopup: getPopup };
+ })();
+
+ // ============================================================
+ // Event Popup (single event detail)
+ // ============================================================
+ var EventPopup = (function () {
+
+ /**
+ * Open event detail popup.
+ * @param {Element} el - Element with data-event-* attributes
+ * @param {object} [opts] - Options
+ * @param {boolean} [opts.hideDatetime] - Hide the date/time field (when opened from day context)
+ */
+ function open(el, opts) {
+ opts = opts || {};
var summary = el.getAttribute('data-event-summary') || '';
var start = el.getAttribute('data-event-start') || '';
@@ -57,25 +179,38 @@
var description = el.getAttribute('data-event-description') || '';
var allDay = el.getAttribute('data-event-allday') === '1';
var slot = el.getAttribute('data-event-slot') || '';
+ var uid = el.getAttribute('data-event-uid') || '';
+ var recurrence = el.getAttribute('data-event-recurrence') || '';
+ var dateIso = el.getAttribute('data-event-date') || '';
var html = '';
- 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);
- }
+ PopupUI.show(html);
}
function close() {
- if (overlay) {
- overlay.style.display = 'none';
+ PopupUI.close();
+ }
+
+ return { open: open, close: close };
+ })();
+
+ // ============================================================
+ // Day Popup (list events for a specific day)
+ // ============================================================
+ var DayPopup = (function () {
+
+ function open(dayCell) {
+ var dateIso = dayCell.getAttribute('data-luxtools-date') || '';
+ if (!dateIso) return;
+
+ var events = [];
+ var rawJson = dayCell.getAttribute('data-luxtools-day-events');
+ if (rawJson) {
+ try { events = JSON.parse(rawJson); } catch (e) { events = []; }
+ }
+
+ var html = '';
+
+ PopupUI.show(html);
+ }
+
+ return { open: open };
+ })();
+
+ // ============================================================
+ // Event Form (create/edit)
+ // ============================================================
+ var EventForm = (function () {
+ var _calendarSlots = null;
+
+ function loadSlots(callback) {
+ if (_calendarSlots) { callback(_calendarSlots); return; }
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('GET', getAjaxUrl() + '?call=luxtools_calendar_slots', true);
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+ xhr.onload = function () {
+ try {
+ var result = JSON.parse(xhr.responseText);
+ if (result.ok && result.slots) {
+ _calendarSlots = result.slots;
+ } else {
+ _calendarSlots = [];
+ }
+ } catch (e) {
+ _calendarSlots = [];
+ }
+ callback(_calendarSlots);
+ };
+ xhr.onerror = function () {
+ _calendarSlots = [];
+ callback(_calendarSlots);
+ };
+ xhr.send();
+ }
+
+ function openCreate(dateIso) {
+ loadSlots(function (slots) {
+ renderForm({
+ mode: 'create',
+ date: dateIso,
+ summary: '',
+ startTime: '',
+ endTime: '',
+ location: '',
+ description: '',
+ allDay: true,
+ slot: (slots.length > 0) ? slots[0].key : 'general',
+ }, slots);
+ });
+ }
+
+ function openEdit(data) {
+ loadSlots(function (slots) {
+ // Parse start/end times
+ var startTime = '';
+ var endTime = '';
+ if (!data.allDay && data.start) {
+ var sd = new Date(data.start);
+ if (!isNaN(sd.getTime())) startTime = pad2(sd.getHours()) + ':' + pad2(sd.getMinutes());
+ }
+ if (!data.allDay && data.end) {
+ var ed = new Date(data.end);
+ if (!isNaN(ed.getTime())) endTime = pad2(ed.getHours()) + ':' + pad2(ed.getMinutes());
+ }
+
+ renderForm({
+ mode: 'edit',
+ uid: data.uid || '',
+ recurrence: data.recurrence || '',
+ date: data.date || '',
+ summary: data.summary || '',
+ startTime: startTime,
+ endTime: endTime,
+ location: data.location || '',
+ description: data.description || '',
+ allDay: data.allDay,
+ slot: data.slot || 'general',
+ }, slots);
+ });
+ }
+
+ function renderForm(data, slots) {
+ var isEdit = data.mode === 'edit';
+ var title = isEdit ? 'Edit Event' : 'Create Event';
+
+ var html = '';
+
+ PopupUI.show(html);
+
+ // Wire up all-day checkbox toggle
+ var popup = PopupUI.getPopup();
+ var allDayCheckbox = popup.querySelector('.luxtools-form-allday');
+ var timeFields = popup.querySelector('.luxtools-event-form-time-fields');
+ if (allDayCheckbox && timeFields) {
+ allDayCheckbox.addEventListener('change', function () {
+ timeFields.style.display = allDayCheckbox.checked ? 'none' : '';
+ });
}
}
- function formatDate(isoStr) {
- if (!isoStr) return '';
- var d = new Date(isoStr);
- if (isNaN(d.getTime())) return isoStr;
- return pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear();
+ function collectFormData() {
+ var popup = PopupUI.getPopup();
+ return {
+ summary: (popup.querySelector('.luxtools-form-summary') || {}).value || '',
+ date: (popup.querySelector('.luxtools-form-date') || {}).value || '',
+ allDay: !!(popup.querySelector('.luxtools-form-allday') || {}).checked,
+ startTime: (popup.querySelector('.luxtools-form-start-time') || {}).value || '',
+ endTime: (popup.querySelector('.luxtools-form-end-time') || {}).value || '',
+ location: (popup.querySelector('.luxtools-form-location') || {}).value || '',
+ description: (popup.querySelector('.luxtools-form-description') || {}).value || '',
+ slot: (popup.querySelector('.luxtools-form-slot') || {}).value || 'general',
+ };
}
- function formatDateTime(isoStr) {
- if (!isoStr) return '';
- var d = new Date(isoStr);
- if (isNaN(d.getTime())) return isoStr;
- return formatDate(isoStr) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ function save(saveBtn) {
+ var mode = saveBtn.getAttribute('data-mode');
+ var formData = collectFormData();
+
+ if (!formData.summary.trim()) {
+ showNotification('Summary is required', 'error');
+ return;
+ }
+ if (!formData.date) {
+ showNotification('Date is required', 'error');
+ return;
+ }
+
+ // For recurring event edits, ask about scope first
+ if (mode === 'edit') {
+ var recurrence = saveBtn.getAttribute('data-recurrence') || '';
+ if (recurrence) {
+ showRecurrenceEditScopeDialog(saveBtn, formData);
+ return;
+ }
+ }
+
+ submitSave(saveBtn, formData, 'all');
}
- function isSameMoment(left, right) {
- if (!left || !right) return false;
- return left === right;
+ function showRecurrenceEditScopeDialog(saveBtn, formData) {
+ var uid = saveBtn.getAttribute('data-uid') || '';
+ var recurrence = saveBtn.getAttribute('data-recurrence') || '';
+
+ var html = '';
+
+ // Store formData on the global scope so the scope button handler can access it
+ _pendingEditFormData = formData;
+ PopupUI.show(html);
}
- function pad2(value) {
- return String(value).padStart(2, '0');
+ function submitSave(saveBtn, formData, scope) {
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving...';
+
+ var mode = saveBtn.getAttribute('data-mode') || 'create';
+
+ var params = 'call=luxtools_calendar_event'
+ + '&action=' + encodeURIComponent(mode === 'edit' ? 'edit' : 'create')
+ + '&summary=' + encodeURIComponent(formData.summary)
+ + '&date=' + encodeURIComponent(formData.date)
+ + '&allday=' + (formData.allDay ? '1' : '0')
+ + '&start_time=' + encodeURIComponent(formData.startTime)
+ + '&end_time=' + encodeURIComponent(formData.endTime)
+ + '&location=' + encodeURIComponent(formData.location)
+ + '&description=' + encodeURIComponent(formData.description)
+ + '&slot=' + encodeURIComponent(formData.slot)
+ + '§ok=' + encodeURIComponent(getSecurityToken(saveBtn));
+
+ if (mode === 'edit') {
+ params += '&uid=' + encodeURIComponent(saveBtn.getAttribute('data-uid') || '');
+ params += '&recurrence=' + encodeURIComponent(saveBtn.getAttribute('data-recurrence') || '');
+ params += '&scope=' + encodeURIComponent(scope);
+ }
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', getAjaxUrl(), true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ xhr.onload = function () {
+ try {
+ var result = JSON.parse(xhr.responseText);
+ if (result.ok) {
+ PopupUI.close();
+ showNotification(result.message || 'Saved', 'success');
+ window.location.reload();
+ } else {
+ showNotification(result.error || 'Save failed', 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save';
+ }
+ } catch (e) {
+ showNotification('Invalid response', 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save';
+ }
+ };
+ xhr.onerror = function () {
+ showNotification('Network error', 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save';
+ };
+ xhr.send(params);
}
- function escapeHtml(text) {
- var div = document.createElement('div');
- div.appendChild(document.createTextNode(text));
- return div.innerHTML;
+ return { openCreate: openCreate, openEdit: openEdit, save: save, submitSave: submitSave };
+ })();
+
+ // ============================================================
+ // Event Deletion
+ // ============================================================
+ var EventDelete = (function () {
+
+ function confirmDelete(btn) {
+ var uid = btn.getAttribute('data-uid') || '';
+ var slot = btn.getAttribute('data-slot') || '';
+ var recurrence = btn.getAttribute('data-recurrence') || '';
+ var dateIso = btn.getAttribute('data-date') || '';
+
+ if (!uid) return;
+
+ // For recurring events, ask about scope
+ if (recurrence) {
+ showRecurrenceDeleteDialog(uid, slot, recurrence, dateIso);
+ return;
+ }
+
+ // Simple confirmation
+ var html = '';
+
+ PopupUI.show(html);
}
- return {
- open: open,
- close: close,
- };
+ function showRecurrenceDeleteDialog(uid, slot, recurrence, dateIso) {
+ var html = '';
+
+ PopupUI.show(html);
+ }
+
+ function executeDelete(btn) {
+ var uid = btn.getAttribute('data-uid') || '';
+ var slot = btn.getAttribute('data-slot') || '';
+ var recurrence = btn.getAttribute('data-recurrence') || '';
+ var dateIso = btn.getAttribute('data-date') || '';
+ var scope = btn.getAttribute('data-scope') || 'all';
+
+ btn.disabled = true;
+ btn.textContent = 'Deleting...';
+
+ var params = 'call=luxtools_calendar_event'
+ + '&action=delete'
+ + '&uid=' + encodeURIComponent(uid)
+ + '&slot=' + encodeURIComponent(slot)
+ + '&recurrence=' + encodeURIComponent(recurrence)
+ + '&date=' + encodeURIComponent(dateIso)
+ + '&scope=' + encodeURIComponent(scope)
+ + '§ok=' + encodeURIComponent(getSecurityToken(btn));
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', getAjaxUrl(), true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ xhr.onload = function () {
+ try {
+ var result = JSON.parse(xhr.responseText);
+ if (result.ok) {
+ PopupUI.close();
+ showNotification(result.message || 'Deleted', 'success');
+ window.location.reload();
+ } else {
+ showNotification(result.error || 'Delete failed', 'error');
+ btn.disabled = false;
+ btn.textContent = btn.getAttribute('data-scope') === 'all' ? 'Delete' : btn.textContent;
+ }
+ } catch (e) {
+ showNotification('Invalid response', 'error');
+ btn.disabled = false;
+ }
+ };
+ xhr.onerror = function () {
+ showNotification('Network error', 'error');
+ btn.disabled = false;
+ };
+ xhr.send(params);
+ }
+
+ return { confirmDelete: confirmDelete, executeDelete: executeDelete };
})();
// ============================================================
@@ -150,32 +714,12 @@
// ============================================================
var MaintenanceTasks = (function () {
- function getSecurityToken(container) {
- var sectok = container ? container.getAttribute('data-luxtools-sectok') : '';
- if (sectok) return String(sectok);
-
- if (window.JSINFO && window.JSINFO.sectok) {
- return String(window.JSINFO.sectok);
- }
-
- var input = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
- if (input && input.value) {
- return String(input.value);
- }
-
- return '';
- }
-
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) item = button.closest('[data-uid]');
if (!item) return;
var uid = item.getAttribute('data-task-uid') || item.getAttribute('data-uid') || '';
@@ -184,15 +728,8 @@
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 = getSecurityToken(container);
-
- if (!ajaxUrl) {
- // Fallback: use DokuWiki's standard AJAX endpoint
- ajaxUrl = (window.DOKU_BASE || '/') + 'lib/exe/ajax.php';
- }
+ var ajaxUrl = getAjaxUrl();
+ var sectok = getSecurityToken(item);
button.disabled = true;
button.textContent = '...';
@@ -210,34 +747,27 @@
xhr.onload = function () {
var result;
- try {
- result = JSON.parse(xhr.responseText);
- } catch (e) {
- result = { ok: false, error: 'Invalid response' };
- }
+ try { result = JSON.parse(xhr.responseText); }
+ catch (e) { result = { ok: false, error: 'Invalid response' }; }
if (result.ok) {
- // Visual feedback: mark item as done or revert
if (action === 'complete') {
item.classList.add('luxtools-task-completed');
button.textContent = 'Reopen';
button.setAttribute('data-action', 'reopen');
- button.disabled = false;
} else {
item.classList.remove('luxtools-task-completed');
item.style.opacity = '1';
button.textContent = 'Complete';
button.setAttribute('data-action', 'complete');
- button.disabled = false;
}
+ 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');
+ showNotification(result.error || 'Action failed', 'error');
button.textContent = action === 'complete' ? 'Complete' : 'Reopen';
button.disabled = false;
}
@@ -252,27 +782,7 @@
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,
- };
+ return { handleAction: handleAction };
})();
// ============================================================
@@ -295,10 +805,82 @@
return;
}
+ // Event form save
+ if (target.classList && target.classList.contains('luxtools-event-form-save')) {
+ e.preventDefault();
+ EventForm.save(target);
+ return;
+ }
+
+ // Event form cancel
+ if (target.classList && target.classList.contains('luxtools-event-form-cancel')) {
+ e.preventDefault();
+ PopupUI.close();
+ return;
+ }
+
+ // Event create button (from day popup)
+ if (target.classList && target.classList.contains('luxtools-event-create-btn')) {
+ e.preventDefault();
+ EventForm.openCreate(target.getAttribute('data-date') || '');
+ return;
+ }
+
+ // Event edit button
+ if (target.classList && target.classList.contains('luxtools-event-edit-btn')) {
+ e.preventDefault();
+ EventForm.openEdit({
+ uid: target.getAttribute('data-uid') || '',
+ slot: target.getAttribute('data-slot') || '',
+ recurrence: target.getAttribute('data-recurrence') || '',
+ date: target.getAttribute('data-date') || '',
+ summary: target.getAttribute('data-summary') || '',
+ start: target.getAttribute('data-start') || '',
+ end: target.getAttribute('data-end') || '',
+ location: target.getAttribute('data-location') || '',
+ description: target.getAttribute('data-description') || '',
+ allDay: target.getAttribute('data-allday') === '1',
+ });
+ return;
+ }
+
+ // Event delete button
+ if (target.classList && target.classList.contains('luxtools-event-delete-btn')) {
+ e.preventDefault();
+ EventDelete.confirmDelete(target);
+ return;
+ }
+
+ // Confirm delete button
+ if (target.classList && target.classList.contains('luxtools-event-confirm-delete')) {
+ e.preventDefault();
+ EventDelete.executeDelete(target);
+ return;
+ }
+
+ // Confirm edit scope button (recurring event edit)
+ if (target.classList && target.classList.contains('luxtools-event-confirm-edit-scope')) {
+ e.preventDefault();
+ if (_pendingEditFormData) {
+ EventForm.submitSave(target, _pendingEditFormData, target.getAttribute('data-scope') || 'all');
+ _pendingEditFormData = null;
+ }
+ return;
+ }
+
+ // Event popup: clicking an event item within the day popup
+ // (items inside the popup that have data-luxtools-event)
+ var dayPopupEvent = target.closest ? target.closest('.luxtools-day-popup-event-item[data-luxtools-event]') : null;
+ if (dayPopupEvent) {
+ if (target.tagName === 'BUTTON' || (target.closest && target.closest('button'))) return;
+ e.preventDefault();
+ EventPopup.open(dayPopupEvent, { hideDatetime: true });
+ 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') {
@@ -310,14 +892,27 @@
}
if (eventEl) {
- // Don't open popup if clicking a button inside the event item
- if (target.tagName === 'BUTTON' || target.closest('button')) return;
+ if (target.tagName === 'BUTTON' || (target.closest && target.closest('button'))) return;
+ // Check if this event is inside a day cell (calendar context)
+ var dayCell = eventEl.closest ? eventEl.closest('td[data-luxtools-day="1"]') : null;
e.preventDefault();
- EventPopup.open(eventEl);
+ EventPopup.open(eventEl, { hideDatetime: !!dayCell });
+ return;
+ }
+
+ // Day cell click: open day popup when clicking empty space
+ var clickedDayCell = target.closest ? target.closest('td[data-luxtools-day="1"]') : null;
+ if (clickedDayCell) {
+ // Don't interfere with link clicks inside the cell
+ if (target.tagName === 'A' || (target.closest && target.closest('a'))) return;
+ e.preventDefault();
+ DayPopup.open(clickedDayCell);
+ return;
}
}, false);
Luxtools.EventPopup = EventPopup;
+ Luxtools.DayPopup = DayPopup;
Luxtools.MaintenanceTasks = MaintenanceTasks;
})();
diff --git a/js/main.js b/js/main.js
index a3a7942..61a629e 100644
--- a/js/main.js
+++ b/js/main.js
@@ -172,6 +172,57 @@
});
}
+ // ============================================================
+ // Calendar Sync Button (syntax widget)
+ // ============================================================
+ function initCalendarSyncButtons() {
+ document.addEventListener('click', function (e) {
+ var btn = e.target;
+ if (!btn || !btn.classList || !btn.classList.contains('luxtools-calendar-sync-btn')) return;
+
+ e.preventDefault();
+
+ var ajaxUrl = btn.getAttribute('data-luxtools-ajax-url') || '';
+ var sectok = btn.getAttribute('data-luxtools-sectok') || '';
+ if (!ajaxUrl) return;
+
+ var status = btn.parentNode ? btn.parentNode.querySelector('.luxtools-calendar-sync-status') : null;
+
+ btn.disabled = true;
+ if (status) {
+ status.textContent = 'Syncing...';
+ status.style.color = '';
+ }
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', ajaxUrl, true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ xhr.onload = function () {
+ btn.disabled = false;
+ try {
+ var r = JSON.parse(xhr.responseText);
+ if (status) {
+ status.textContent = r.message || (r.ok ? 'Done' : 'Failed');
+ status.style.color = r.ok ? 'green' : 'red';
+ }
+ } catch (ex) {
+ if (status) {
+ status.textContent = 'Error';
+ status.style.color = 'red';
+ }
+ }
+ };
+ xhr.onerror = function () {
+ btn.disabled = false;
+ if (status) {
+ status.textContent = 'Network error';
+ status.style.color = 'red';
+ }
+ };
+ xhr.send('call=luxtools_calendar_sync§ok=' + encodeURIComponent(sectok));
+ }, false);
+ }
+
// ============================================================
// Initialize
// ============================================================
@@ -181,6 +232,7 @@
initChronologicalEventTimes();
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
initPurgeCacheDialog();
+ initCalendarSyncButtons();
}, false);
document.addEventListener('DOMContentLoaded', function () {
if (Scratchpads && Scratchpads.init) Scratchpads.init();
diff --git a/lang/de/lang.php b/lang/de/lang.php
index 9d52fc7..9f5e89f 100644
--- a/lang/de/lang.php
+++ b/lang/de/lang.php
@@ -131,3 +131,11 @@ $lang["movie_error_fetch"] = "OMDb-Abfrage fehlgeschlagen.";
$lang["omdb_heading"] = "Film-Import (OMDb)";
$lang["omdb_apikey"] = "OMDb-API-Schlüssel";
$lang["omdb_apikey_note"] = "Der API-Schlüssel wird an den Browser übergeben für clientseitige OMDb-Abfragen. Er ist in den Browser-Entwicklertools und Netzwerkanfragen sichtbar.";
+
+$lang["event_create_success"] = "Termin erstellt.";
+$lang["event_create_error"] = "Termin konnte nicht erstellt werden.";
+$lang["event_edit_success"] = "Termin aktualisiert.";
+$lang["event_edit_error"] = "Termin konnte nicht aktualisiert werden.";
+$lang["event_delete_success"] = "Termin gelöscht.";
+$lang["event_delete_error"] = "Termin konnte nicht gelöscht werden.";
+$lang["event_caldav_push_failed"] = "Lokale Aktualisierung erfolgreich, aber CalDAV-Upload fehlgeschlagen.";
diff --git a/lang/en/lang.php b/lang/en/lang.php
index 25b4da4..f7e78c6 100644
--- a/lang/en/lang.php
+++ b/lang/en/lang.php
@@ -132,3 +132,11 @@ $lang["movie_error_fetch"] = "OMDb lookup failed.";
$lang["omdb_heading"] = "Movie Import (OMDb)";
$lang["omdb_apikey"] = "OMDb API key";
$lang["omdb_apikey_note"] = "The API key is passed to the browser for client-side OMDb lookups. It will be visible in browser developer tools and network requests.";
+
+$lang["event_create_success"] = "Event created.";
+$lang["event_create_error"] = "Failed to create event.";
+$lang["event_edit_success"] = "Event updated.";
+$lang["event_edit_error"] = "Failed to update event.";
+$lang["event_delete_success"] = "Event deleted.";
+$lang["event_delete_error"] = "Failed to delete event.";
+$lang["event_caldav_push_failed"] = "Local update succeeded, but CalDAV upload failed.";
diff --git a/src/CalDavClient.php b/src/CalDavClient.php
index f05edad..5c972b4 100644
--- a/src/CalDavClient.php
+++ b/src/CalDavClient.php
@@ -395,6 +395,68 @@ class CalDavClient
}
}
+ /**
+ * Public wrapper for findObjectByUid.
+ *
+ * @param string $caldavUrl
+ * @param string $username
+ * @param string $password
+ * @param string $uid
+ * @return array{href: string, etag: string, data: string}|null
+ */
+ public static function findObjectByUidPublic(
+ string $caldavUrl,
+ string $username,
+ string $password,
+ string $uid
+ ): ?array {
+ return self::findObjectByUid($caldavUrl, $username, $password, $uid);
+ }
+
+ /**
+ * Public wrapper for putCalendarObject.
+ *
+ * @param string $href
+ * @param string $username
+ * @param string $password
+ * @param string $data
+ * @param string $etag
+ * @return string Empty string on success, error on failure
+ */
+ public static function putCalendarObjectPublic(
+ string $href,
+ string $username,
+ string $password,
+ string $data,
+ string $etag
+ ): string {
+ return self::putCalendarObject($href, $username, $password, $data, $etag);
+ }
+
+ /**
+ * Delete a calendar object from the server.
+ *
+ * @param string $href Full URL of the calendar object
+ * @param string $username
+ * @param string $password
+ * @param string $etag ETag for If-Match header (empty to skip)
+ * @return bool True on success
+ */
+ public static function deleteCalendarObject(
+ string $href,
+ string $username,
+ string $password,
+ string $etag
+ ): bool {
+ $headers = [];
+ if ($etag !== '') {
+ $headers[] = 'If-Match: ' . $etag;
+ }
+
+ $response = self::request('DELETE', $href, $username, $password, '', $headers);
+ return $response !== null;
+ }
+
/**
* Perform an HTTP request to a CalDAV server.
*
diff --git a/src/CalendarSyncService.php b/src/CalendarSyncService.php
new file mode 100644
index 0000000..10c2386
--- /dev/null
+++ b/src/CalendarSyncService.php
@@ -0,0 +1,40 @@
+}
+ */
+ public static function syncAll(array $slots): array
+ {
+ $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();
+
+ return [
+ 'ok' => !$hasErrors,
+ 'results' => $results,
+ ];
+ }
+}
diff --git a/src/ChronologicalCalendarWidget.php b/src/ChronologicalCalendarWidget.php
index 6b45b39..2d7114b 100644
--- a/src/ChronologicalCalendarWidget.php
+++ b/src/ChronologicalCalendarWidget.php
@@ -132,7 +132,13 @@ class ChronologicalCalendarWidget
$classes .= ' luxtools-calendar-day-has-events';
}
- $html .= '';
+ // Encode day event data as JSON for the day popup
+ $dayEventsJson = self::encodeDayEventsJson($events);
+ $dayDataAttr = $dayEventsJson !== '[]'
+ ? ' data-luxtools-day-events="' . hsc($dayEventsJson) . '"'
+ : '';
+
+ $html .= ' ';
if ($size === 'small') {
$dayIndicators = $indicators[$date] ?? [];
@@ -277,7 +283,47 @@ class ChronologicalCalendarWidget
}
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
+ if ($event->uid !== '') {
+ $attrs .= ' data-event-uid="' . hsc($event->uid) . '"';
+ }
+ if ($event->recurrenceId !== '') {
+ $attrs .= ' data-event-recurrence="' . hsc($event->recurrenceId) . '"';
+ }
+ if ($event->dateIso !== '') {
+ $attrs .= ' data-event-date="' . hsc($event->dateIso) . '"';
+ }
return $attrs;
}
+
+ /**
+ * Encode day events as a JSON array for the day popup.
+ *
+ * @param CalendarEvent[] $events
+ * @return string JSON string
+ */
+ protected static function encodeDayEventsJson(array $events): string
+ {
+ if ($events === []) return '[]';
+
+ $items = [];
+ foreach ($events as $event) {
+ $item = [
+ 'summary' => $event->summary,
+ 'start' => $event->startIso,
+ 'allDay' => $event->allDay,
+ 'slot' => $event->slotKey,
+ ];
+ if ($event->endIso !== '') $item['end'] = $event->endIso;
+ if ($event->location !== '') $item['location'] = $event->location;
+ if ($event->description !== '') $item['description'] = $event->description;
+ if ($event->time !== '') $item['time'] = $event->time;
+ if ($event->uid !== '') $item['uid'] = $event->uid;
+ if ($event->recurrenceId !== '') $item['recurrence'] = $event->recurrenceId;
+ if ($event->dateIso !== '') $item['date'] = $event->dateIso;
+ $items[] = $item;
+ }
+
+ return json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT);
+ }
}
diff --git a/src/IcsWriter.php b/src/IcsWriter.php
index 4f24ce8..29e2074 100644
--- a/src/IcsWriter.php
+++ b/src/IcsWriter.php
@@ -265,6 +265,459 @@ class IcsWriter
return self::atomicWrite($filePath, $content);
}
+ /**
+ * Create a new event in a local ICS file.
+ *
+ * @param string $filePath
+ * @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
+ * @return string The UID of the created event, or empty string on failure
+ */
+ public static function createEvent(string $filePath, array $eventData): string
+ {
+ if ($filePath === '') return '';
+
+ try {
+ $calendar = null;
+ if (is_file($filePath) && is_readable($filePath)) {
+ $raw = @file_get_contents($filePath);
+ if (is_string($raw) && trim($raw) !== '') {
+ $calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
+ if (!($calendar instanceof VCalendar)) {
+ $calendar = null;
+ }
+ }
+ }
+
+ if ($calendar === null) {
+ $calendar = new VCalendar();
+ $calendar->PRODID = '-//LuxTools DokuWiki Plugin//EN';
+ }
+
+ $uid = self::generateUid();
+ $props = [
+ 'UID' => $uid,
+ 'SUMMARY' => $eventData['summary'] ?? '',
+ 'DTSTAMP' => gmdate('Ymd\THis\Z'),
+ ];
+
+ if (!empty($eventData['location'])) {
+ $props['LOCATION'] = $eventData['location'];
+ }
+ if (!empty($eventData['description'])) {
+ $props['DESCRIPTION'] = $eventData['description'];
+ }
+
+ $dateIso = $eventData['date'] ?? '';
+ $allDay = !empty($eventData['allDay']);
+
+ if ($allDay) {
+ $props['DTSTART'] = str_replace('-', '', $dateIso);
+ $vevent = $calendar->add('VEVENT', $props);
+ $vevent->DTSTART['VALUE'] = 'DATE';
+ } else {
+ $startTime = $eventData['startTime'] ?? '00:00';
+ $endTime = $eventData['endTime'] ?? '';
+ $props['DTSTART'] = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
+ $vevent = $calendar->add('VEVENT', $props);
+ if ($endTime !== '') {
+ $vevent->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
+ }
+ }
+
+ $output = $calendar->serialize();
+ if (!self::atomicWritePublic($filePath, $output)) {
+ return '';
+ }
+
+ return $uid;
+ } catch (Throwable $e) {
+ return '';
+ }
+ }
+
+ /**
+ * Edit an existing event in a local ICS file.
+ *
+ * Scope controls how recurring event edits are applied:
+ * - 'all': modify the master event directly
+ * - 'this': create/update an occurrence override
+ * - 'future': truncate the master's RRULE before this date and create a new series
+ *
+ * @param string $filePath
+ * @param string $uid
+ * @param string $recurrenceId
+ * @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
+ * @param string $scope 'all', 'this', or 'future'
+ * @return bool
+ */
+ public static function editEvent(string $filePath, string $uid, string $recurrenceId, array $eventData, string $scope = 'all'): bool
+ {
+ if ($filePath === '' || $uid === '') 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;
+
+ $dateIso = $eventData['date'] ?? '';
+ $allDay = !empty($eventData['allDay']);
+
+ if ($scope === 'future' && $recurrenceId !== '') {
+ // "This and future": truncate master RRULE, then create a new standalone event
+ $edited = self::editFutureOccurrences($calendar, $uid, $dateIso, $eventData);
+ if (!$edited) return false;
+ } elseif ($scope === 'all') {
+ // "All occurrences": find and edit the master event directly
+ $master = self::findMasterByUid($calendar, $uid);
+ if ($master === null) return false;
+ self::applyEventData($master, $eventData);
+ } else {
+ // "This occurrence" (or non-recurring): find/create occurrence override
+ $target = null;
+
+ // Find matching component
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
+ $target = $component;
+ break;
+ }
+ }
+
+ // For recurring events without an existing override, create one
+ if ($target === null && $recurrenceId !== '') {
+ $target = self::createEditOccurrenceOverride($calendar, $uid, $recurrenceId, $dateIso, $eventData);
+ }
+
+ if ($target === null) return false;
+ self::applyEventData($target, $eventData);
+ }
+
+ $output = $calendar->serialize();
+ return self::atomicWrite($filePath, $output);
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Find the master VEVENT (the one with RRULE or without RECURRENCE-ID) by UID.
+ */
+ private static function findMasterByUid(VCalendar $calendar, string $uid): ?VEvent
+ {
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ $componentUid = trim((string)($component->UID ?? ''));
+ if ($componentUid !== $uid) continue;
+ // Master = has no RECURRENCE-ID
+ if (!isset($component->{'RECURRENCE-ID'})) {
+ return $component;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Create an occurrence override VEVENT for a recurring event.
+ */
+ private static function createEditOccurrenceOverride(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso, array $eventData): ?VEvent
+ {
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ $componentUid = trim((string)($component->UID ?? ''));
+ if ($componentUid !== $uid) continue;
+ if (!isset($component->RRULE)) continue;
+
+ $overrideProps = [
+ 'UID' => $uid,
+ 'SUMMARY' => $eventData['summary'] ?? '',
+ ];
+
+ $isAllDayMaster = strtoupper((string)($component->DTSTART['VALUE'] ?? '')) === 'DATE';
+ if ($isAllDayMaster) {
+ $recurrenceValue = str_replace('-', '', $dateIso);
+ $overrideProps['RECURRENCE-ID'] = $recurrenceValue;
+ } else {
+ $masterStart = $component->DTSTART->getDateTime();
+ $recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
+ $tz = $masterStart->getTimezone();
+ if ($tz && $tz->getName() !== 'UTC') {
+ $overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue);
+ } else {
+ $overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue) . 'Z';
+ }
+ }
+
+ $target = $calendar->add('VEVENT', $overrideProps);
+ if ($isAllDayMaster) {
+ $target->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
+ } else {
+ $masterStart = $component->DTSTART->getDateTime();
+ $tz = $masterStart->getTimezone();
+ if ($tz && $tz->getName() !== 'UTC') {
+ $target->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
+ }
+ }
+ return $target;
+ }
+ return null;
+ }
+
+ /**
+ * Apply event data to a VEVENT component (shared by all edit scopes).
+ */
+ private static function applyEventData(VEvent $target, array $eventData): void
+ {
+ $dateIso = $eventData['date'] ?? '';
+ $allDay = !empty($eventData['allDay']);
+
+ $target->SUMMARY = $eventData['summary'] ?? '';
+
+ if ($allDay) {
+ $target->DTSTART = str_replace('-', '', $dateIso);
+ $target->DTSTART['VALUE'] = 'DATE';
+ unset($target->DTEND);
+ } else {
+ $startTime = $eventData['startTime'] ?? '00:00';
+ $endTime = $eventData['endTime'] ?? '';
+ $target->DTSTART = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
+ if ($endTime !== '') {
+ if (isset($target->DTEND)) {
+ $target->DTEND = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00';
+ } else {
+ $target->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
+ }
+ }
+ }
+
+ if (!empty($eventData['location'])) {
+ $target->LOCATION = $eventData['location'];
+ } else {
+ unset($target->LOCATION);
+ }
+
+ if (!empty($eventData['description'])) {
+ $target->DESCRIPTION = $eventData['description'];
+ } else {
+ unset($target->DESCRIPTION);
+ }
+ }
+
+ /**
+ * Handle "this and future" edits: truncate master RRULE before dateIso,
+ * then create a new standalone event with the edited data.
+ */
+ private static function editFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso, array $eventData): bool
+ {
+ $master = self::findMasterByUid($calendar, $uid);
+ if ($master === null) return false;
+
+ // Truncate master RRULE to end before this date
+ self::deleteFutureOccurrences($calendar, $uid, $dateIso);
+
+ // Create a new standalone event with the edited data and a new UID
+ $newProps = [
+ 'UID' => self::generateUid(),
+ 'SUMMARY' => $eventData['summary'] ?? '',
+ 'DTSTAMP' => gmdate('Ymd\THis\Z'),
+ ];
+ $newEvent = $calendar->add('VEVENT', $newProps);
+ self::applyEventData($newEvent, $eventData);
+
+ return true;
+ }
+
+ /**
+ * Delete an event from a local ICS file.
+ *
+ * @param string $filePath
+ * @param string $uid
+ * @param string $recurrenceId
+ * @param string $dateIso
+ * @param string $scope 'all', 'this', or 'future'
+ * @return bool
+ */
+ public static function deleteEvent(string $filePath, string $uid, string $recurrenceId, string $dateIso, string $scope = 'all'): bool
+ {
+ if ($filePath === '' || $uid === '') 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;
+
+ if ($scope === 'all') {
+ // Remove all components with this UID
+ self::removeComponentsByUid($calendar, $uid);
+ } elseif ($scope === 'this') {
+ // For a single occurrence, add EXDATE to master or remove the override
+ self::deleteOccurrence($calendar, $uid, $recurrenceId, $dateIso);
+ } elseif ($scope === 'future') {
+ // Modify RRULE UNTIL on the master
+ self::deleteFutureOccurrences($calendar, $uid, $dateIso);
+ } else {
+ return false;
+ }
+
+ $output = $calendar->serialize();
+ return self::atomicWrite($filePath, $output);
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Remove all VEVENT components with a given UID.
+ *
+ * @param VCalendar $calendar
+ * @param string $uid
+ */
+ protected static function removeComponentsByUid(VCalendar $calendar, string $uid): void
+ {
+ $toRemove = [];
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (trim((string)($component->UID ?? '')) === $uid) {
+ $toRemove[] = $component;
+ }
+ }
+ foreach ($toRemove as $component) {
+ $calendar->remove($component);
+ }
+ }
+
+ /**
+ * Delete a single occurrence of a recurring event.
+ *
+ * If the occurrence has an override component, remove it and add EXDATE.
+ * If not, just add EXDATE to the master.
+ *
+ * @param VCalendar $calendar
+ * @param string $uid
+ * @param string $recurrenceId
+ * @param string $dateIso
+ */
+ protected static function deleteOccurrence(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso): void
+ {
+ // Remove any existing override for this occurrence
+ $toRemove = [];
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (trim((string)($component->UID ?? '')) !== $uid) continue;
+ if (!isset($component->{'RECURRENCE-ID'})) continue;
+
+ $rid = $component->{'RECURRENCE-ID'}->getDateTime();
+ if ($rid !== null && $rid->format('Y-m-d') === $dateIso) {
+ $toRemove[] = $component;
+ }
+ }
+ foreach ($toRemove as $component) {
+ $calendar->remove($component);
+ }
+
+ // Add EXDATE to the master
+ $master = null;
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (trim((string)($component->UID ?? '')) !== $uid) continue;
+ if (isset($component->{'RECURRENCE-ID'})) continue;
+ $master = $component;
+ break;
+ }
+
+ if ($master !== null) {
+ $isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
+ if ($isAllDay) {
+ $exdateValue = str_replace('-', '', $dateIso);
+ $exdate = $master->add('EXDATE', $exdateValue);
+ $exdate['VALUE'] = 'DATE';
+ } else {
+ $masterStart = $master->DTSTART->getDateTime();
+ $exdateValue = str_replace('-', '', $dateIso) . 'T' . $masterStart->format('His');
+ $tz = $masterStart->getTimezone();
+ if ($tz && $tz->getName() !== 'UTC') {
+ $exdate = $master->add('EXDATE', $exdateValue);
+ $exdate['TZID'] = $tz->getName();
+ } else {
+ $master->add('EXDATE', $exdateValue . 'Z');
+ }
+ }
+ }
+ }
+
+ /**
+ * Delete this and all future occurrences by setting UNTIL on the master RRULE.
+ *
+ * Also removes any override components on or after the given date.
+ *
+ * @param VCalendar $calendar
+ * @param string $uid
+ * @param string $dateIso
+ */
+ protected static function deleteFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso): void
+ {
+ // Remove overrides on or after dateIso
+ $toRemove = [];
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (trim((string)($component->UID ?? '')) !== $uid) continue;
+ if (!isset($component->{'RECURRENCE-ID'})) continue;
+
+ $rid = $component->{'RECURRENCE-ID'}->getDateTime();
+ if ($rid !== null && $rid->format('Y-m-d') >= $dateIso) {
+ $toRemove[] = $component;
+ }
+ }
+ foreach ($toRemove as $component) {
+ $calendar->remove($component);
+ }
+
+ // Set UNTIL on the master RRULE to the day before
+ $master = null;
+ foreach ($calendar->select('VEVENT') as $component) {
+ if (!($component instanceof VEvent)) continue;
+ if (trim((string)($component->UID ?? '')) !== $uid) continue;
+ if (isset($component->{'RECURRENCE-ID'})) continue;
+ $master = $component;
+ break;
+ }
+
+ if ($master !== null && isset($master->RRULE)) {
+ $rrule = (string)$master->RRULE;
+ // Remove existing UNTIL or COUNT
+ $rrule = preg_replace('/;?(UNTIL|COUNT)=[^;]*/i', '', $rrule);
+ // Calculate the day before
+ try {
+ $until = new \DateTimeImmutable($dateIso . ' 00:00:00', new \DateTimeZone('UTC'));
+ $until = $until->sub(new \DateInterval('P1D'));
+ $rrule .= ';UNTIL=' . $until->format('Ymd') . 'T235959Z';
+ } catch (Throwable $e) {
+ return;
+ }
+ $master->RRULE = $rrule;
+ }
+ }
+
+ /**
+ * Generate a unique UID for a new calendar event.
+ *
+ * @return string
+ */
+ protected static function generateUid(): string
+ {
+ return sprintf(
+ '%s-%s@luxtools',
+ gmdate('Ymd-His'),
+ bin2hex(random_bytes(8))
+ );
+ }
+
/**
* Atomic file write using a temp file and rename.
*
diff --git a/style.css b/style.css
index 03bb85c..d9336e5 100644
--- a/style.css
+++ b/style.css
@@ -973,6 +973,84 @@ li.luxtools-task-overdue .luxtools-task-date {
font-size: 0.9em;
}
+/* Event popup action buttons */
+.luxtools-event-popup-actions {
+ margin-top: 1em;
+ padding-top: 0.75em;
+ border-top: 1px solid @ini_border;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+}
+
+.luxtools-recurrence-actions {
+ flex-direction: column;
+}
+
+/* Day popup */
+.luxtools-day-popup-events {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.luxtools-day-popup-event-item {
+ padding: 0.3em 0.4em;
+ margin: 0.2em 0;
+ border-radius: 0.2em;
+ cursor: pointer;
+}
+
+.luxtools-day-popup-event-item:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+
+.luxtools-day-popup-empty {
+ opacity: 0.6;
+ font-style: italic;
+}
+
+/* Event form */
+.luxtools-event-form .luxtools-event-form-field {
+ margin: 0.5em 0;
+}
+
+.luxtools-event-form .luxtools-event-form-field label {
+ display: block;
+}
+
+.luxtools-event-form .luxtools-event-form-field input[type="text"],
+.luxtools-event-form .luxtools-event-form-field input[type="date"],
+.luxtools-event-form .luxtools-event-form-field input[type="time"],
+.luxtools-event-form .luxtools-event-form-field textarea,
+.luxtools-event-form .luxtools-event-form-field select {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.luxtools-event-form-time-fields {
+ display: flex;
+ gap: 0.75em;
+}
+
+.luxtools-event-form-time-fields .luxtools-event-form-field {
+ flex: 1;
+}
+
+/* Calendar sync widget (syntax) */
+.luxtools-calendar-sync-widget {
+ margin: 0.5em 0;
+}
+
+.luxtools-calendar-sync-status {
+ margin-left: 0.75em;
+}
+
+/* Clickable day cells */
+td.luxtools-calendar-day[data-luxtools-day] {
+ cursor: pointer;
+}
+
/* ============================================================
* Notifications (fallback)
diff --git a/syntax/calendarsync.php b/syntax/calendarsync.php
new file mode 100644
index 0000000..2800560
--- /dev/null
+++ b/syntax/calendarsync.php
@@ -0,0 +1,79 @@
+}}
+ */
+class syntax_plugin_luxtools_calendarsync 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('\{\{calendar_sync>\}\}', $mode, 'plugin_luxtools_calendarsync');
+ }
+
+ /** @inheritdoc */
+ public function handle($match, $state, $pos, Doku_Handler $handler)
+ {
+ return [];
+ }
+
+ /** @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();
+
+ // Only render for authenticated users
+ if (empty($_SERVER['REMOTE_USER'])) {
+ return true;
+ }
+
+ $ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
+ $sectok = function_exists('getSecurityToken') ? getSecurityToken() : '';
+
+ $buttonLabel = (string)$this->getLang('calendar_sync_button');
+ if ($buttonLabel === '') $buttonLabel = 'Sync Calendars';
+
+ $renderer->doc .= '';
+ $renderer->doc .= '' . hsc($buttonLabel) . ' ';
+ $renderer->doc .= ' ';
+ $renderer->doc .= '
';
+
+ return true;
+ }
+}
diff --git a/task.prompt.md b/task.prompt.md
new file mode 100644
index 0000000..2149600
--- /dev/null
+++ b/task.prompt.md
@@ -0,0 +1,177 @@
+# Calendar Improvements
+
+## Goal
+
+Improve the calendar feature so that calendar sync is easier to trigger, day and event popups are more useful, and calendar events can be created, edited, and deleted from the UI.
+
+This prompt is intended for an implementation agent. Before changing code, inspect the current implementation and keep changes aligned with existing Dokuwiki and plugin conventions.
+
+## Relevant Code Areas
+
+- `admin/main.php`: admin page that already exposes a manual calendar sync button
+- `action.php`: currently contains calendar AJAX handlers, including manual sync
+- `js/event-popup.js`: current popup behavior for calendar events
+- `syntax/calendar.php`: existing calendar syntax implementation and parsing style
+- `src/ChronologicalCalendarWidget.php`: server-rendered calendar widget HTML and event/day markup
+- `README.md`: should be updated if user-facing syntax or behavior changes
+
+## Current State
+
+- Manual calendar sync already exists, but only through the admin page.
+- Event popups currently open when clicking an event element, not when clicking empty space in a day cell.
+- Event popup currently shows the date/time block.
+- Calendar sync is currently handled in `action.php`.
+- Sync is currently one-directional: remote CalDAV -> local `.ics` file.
+- The README already notes that automatic sync is not implemented and would likely require cron.
+
+## Requested Work
+
+### 1. Add a Manual Sync Syntax
+
+Add a new wiki syntax that renders a manual sync control on normal pages, so sync is not limited to the admin page.
+
+Requirements:
+
+- Reuse the existing sync behavior instead of duplicating logic.
+- Keep permission checks strict. The current sync endpoint is admin-only; preserve that unless a strong reason emerges to change it.
+- Follow the existing syntax plugin style used in `syntax/calendar.php`.
+- Update `README.md` with syntax usage and any permission limitations.
+
+Preferred implementation direction:
+
+- Move sync logic out of `action.php` into a dedicated class/service if that simplifies reuse and maintainability.
+- The syntax should only provide UI/rendering; the actual sync work should remain centralized.
+
+Clarification needed:
+
+- The exact syntax name and parameters are not specified. If no better convention exists in the codebase, choose a minimal, explicit syntax and document it.
+-> Use the syntax name `calendar_sync` with no parameters.
+
+### 2. Improve Event Popups
+
+Update the event popup behavior in `js/event-popup.js` and related server-rendered markup.
+
+Requirements:
+
+- Do not show the date in the popup when the popup is opened from a specific day context.
+- Clicking empty space inside a calendar day cell should open a popup for that day.
+- That day-level popup should list all events for the clicked day.
+- Preserve support for clicking an individual event to inspect that specific event.
+
+Implementation notes:
+
+- Inspect how day cells and event items are rendered in `src/ChronologicalCalendarWidget.php` and related output in `action.php`.
+- Prefer attaching structured data to the rendered day cell rather than scraping text from the DOM.
+- Keep the popup accessible: overlay close, escape key, and explicit close button should continue to work.
+
+Clarification needed:
+
+- The desired behavior when a day has no events is not specified. Reasonable default: show a popup with a short "no events" message plus the create action if event creation is implemented.
+-> yes, show a "no events" message with the create action if implemented.
+
+### 3. Add Event Creation
+
+Add a `Create Event` action to the day popup.
+
+Requirements:
+
+- The action should create a new calendar event for the selected day.
+- After successful creation, the UI should reflect the change without requiring the user to manually refresh unrelated state.
+- Keep the implementation maintainable and explicit.
+
+Clarification needed:
+
+- The request does not specify the input UI for creation. The agent should either:
+ - implement a simple popup form, or
+ - stop and ask for clarification if the appropriate form fields are unclear after inspecting the data model.
+- The target calendar slot for newly created events is not specified.
+- It is not specified whether creation should be admin-only, authenticated-user-only, or available to all viewers.
+- It is not specified whether newly created events must be written only to the local `.ics` file or also to remote CalDAV immediately.
+
+-> Implement a simple input ui as a popup with the same styling as the event popup, with fields for summary, date/time, location, and description. Show a dropdown to select the calendar slot, preselecting the first one. Restrict event creation to authenticated users. Write newly created events to the local `.ics` file only, after that perform the event creation via CalDAV if implemented to prevent a full re-sync.
+
+### 4. Add Event Editing
+
+Add an `Edit` action for existing events.
+
+Requirements:
+
+- The action should allow modification of an existing event from the popup.
+- The correct event instance must be edited, including enough identifier data to distinguish recurring occurrences if necessary.
+- After successful editing, refresh the affected day/event UI.
+
+Clarification needed:
+
+- The editable field set is not defined. At minimum, summary, date/time, location, and description appear relevant, but confirm against the current event model before implementing.
+- Recurring event editing semantics are not defined: edit one occurrence vs. entire series.
+- Remote write-back expectations are not defined.
+
+-> re-use the same input UI as creation, but pre-populate fields with the existing event data.
+When trying to save a recurring event, ask the user if they want to edit just the selected occurrence or the entire series, or all future occurrences. The changes should be written to the local `.ics` file first, then if CalDAV write-back is implemented, perform the edit via CalDAV to prevent a full re-sync.
+
+### 5. Add Event Deletion
+
+Add a `Delete` action for existing events.
+
+Requirements:
+
+- Deletion should be available from the event popup.
+- Use an explicit confirmation step to avoid accidental deletion.
+- After deletion, update the UI and related calendar data.
+
+Clarification needed:
+
+- Recurring event deletion semantics are not defined: delete one occurrence vs. entire series.
+- Remote write-back expectations are not defined.
+
+-> When trying to delete a recurring event, ask the user if they want to delete just the selected occurrence or the entire series, or all future occurrences. Perform deletion on the local `.ics` file first, then if CalDAV write-back is implemented, perform the deletion via CalDAV to prevent a full re-sync.
+
+### 6. Automatic Sync
+
+Do not treat this as a mandatory coding task unless the implementation path is already obvious and safe.
+
+Required output:
+
+- Provide a short implementation proposal for automatic sync.
+- Compare at least these approaches:
+ - cron invoking a Dokuwiki entry point or plugin-specific script
+ - cron calling the existing AJAX endpoint
+- Recommend the most maintainable option for a long-lived personal Dokuwiki plugin.
+
+Important context:
+
+- The current README already suggests cron as the likely approach.
+- A direct web or AJAX trigger from cron may work, but a CLI-oriented entry point may be more robust and easier to secure.
+
+### 7. Direct Sync of Changed Events
+
+When an event is created, edited, or deleted, the calendar should update promptly so the UI and stored data stay in sync.
+
+Requirements:
+
+- The local calendar data and rendered calendar state should reflect the change immediately after a successful mutation.
+- If remote CalDAV write-back is implemented for create, edit, or delete, trigger it as part of the mutation flow rather than relying on a later full sync.
+- Avoid introducing a design where a later full remote -> local sync silently overwrites recent local edits without warning.
+
+Clarification needed:
+
+- The current system only guarantees remote -> local full sync, with limited immediate remote write-back behavior for maintenance task completion. Create, edit, and delete may require a broader sync design decision before implementation.
+
+-> Implement immediate local updates to the `.ics` file and calendar state for create, edit, and delete actions. If remote CalDAV write-back is implemented, perform that action immediately after the local update within the same user flow to ensure consistency. Avoid a design where local edits are at risk of being overwritten by a later remote sync without user awareness.
+
+## Refactoring Requirement
+
+If calendar sync code is touched, strongly prefer moving reusable sync logic out of `action.php` into a dedicated class under `src/`.
+
+Reason:
+
+- `action.php` currently mixes hook registration, rendering-related behavior, and calendar sync handling.
+- Reusable sync and event mutation logic will be easier to test, reuse, and extend from a dedicated class.
+
+## Expected Deliverables
+
+1. Implement the clearly defined items that can be completed safely from the current codebase.
+2. Mark any blocked items with precise clarification questions instead of guessing.
+3. Update `README.md` for any new syntax, permissions, or workflow changes.
+4. Run `php -l` on changed PHP files.
+5. Summarize remaining design decisions, especially around permissions, recurring events, and remote CalDAV write-back.