improve calendar features

This commit is contained in:
2026-03-18 14:17:38 +01:00
parent 14d4a2895a
commit 975e195ae3
13 changed files with 2274 additions and 138 deletions

View File

@@ -8,6 +8,7 @@ use dokuwiki\plugin\luxtools\CalDavClient;
use dokuwiki\plugin\luxtools\CalendarEvent;
use dokuwiki\plugin\luxtools\CalendarService;
use dokuwiki\plugin\luxtools\CalendarSlot;
use dokuwiki\plugin\luxtools\CalendarSyncService;
use dokuwiki\plugin\luxtools\ChronoID;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
@@ -87,6 +88,18 @@ class action_plugin_luxtools extends ActionPlugin
$this,
"handleCalendarSyncAction",
);
$controller->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 '<li' . $dataAttrs . '><span class="luxtools-event-summary">' . $summaryHtml . '</span></li>';
@@ -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.
*