register_hook( "TPL_METAHEADER_OUTPUT", "BEFORE", $this, "addScripts", ); $controller->register_hook( "CSS_STYLES_INCLUDED", "BEFORE", $this, "addDialogCss", ); $controller->register_hook( "DOKUWIKI_STARTED", "AFTER", $this, "provideJsInfo", ); $controller->register_hook( "RENDERER_CONTENT_POSTPROCESS", "BEFORE", $this, "autoLinkChronologicalDates", ); $controller->register_hook( "RENDERER_CONTENT_POSTPROCESS", "BEFORE", $this, "appendChronologicalDayEvents", ); $controller->register_hook( "RENDERER_CONTENT_POSTPROCESS", "BEFORE", $this, "appendChronologicalDayPhotos", ); $controller->register_hook( "COMMON_PAGETPL_LOAD", "BEFORE", $this, "prefillChronologicalDayTemplate", ); $controller->register_hook( "TPL_ACT_RENDER", "BEFORE", $this, "renderVirtualChronologicalDayPage", ); $controller->register_hook( "AJAX_CALL_UNKNOWN", "BEFORE", $this, "handleCalendarWidgetAjax", ); $controller->register_hook( "AJAX_CALL_UNKNOWN", "BEFORE", $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", $this, "handleInvalidateCacheAction", ); $controller->register_hook( "MENU_ITEMS_ASSEMBLY", "AFTER", $this, "addInvalidateCacheMenuItem", ); $controller->register_hook( "TOOLBAR_DEFINE", "AFTER", $this, "addToolbarButton", ); } /** * Add plugin JavaScript files in a deterministic order. * * @param Event $event * @param mixed $param * @return void */ public function addScripts(Event $event, $param) { $plugin = $this->getPluginName(); $base = DOKU_BASE . "lib/plugins/$plugin/js/"; $scripts = [ "lightbox.js", "gallery-thumbnails.js", "open-service.js", "scratchpads.js", "date-fix.js", "page-link.js", "linkfavicon.js", "calendar-widget.js", "dialog.js", "event-popup.js", "movie-import.js", "main.js", ]; foreach ($scripts as $script) { $event->data["script"][] = [ "type" => "text/javascript", "src" => $base . $script, "defer" => "defer", ]; } } /** * Register dialog.css with DokuWiki's CSS dispatcher so that * @ini_* variables are resolved and the file is properly cached. */ public function addDialogCss(Event $event, $param) { if (($event->data["mediatype"] ?? "") !== "screen") { return; } $plugin = $this->getPluginName(); $file = DOKU_PLUGIN . "$plugin/dialog.css"; $location = DOKU_BASE . "lib/plugins/$plugin/"; if (!file_exists($file)) { return; } $event->data["files"][$file] = $location; } /** * Pass plugin data to client-side JavaScript via JSINFO. * * Must run before TPL_METAHEADER_OUTPUT because JSINFO is serialized * during tpl_metaheaders() before that event fires. * * @param Event $event * @param mixed $param * @return void */ public function provideJsInfo(Event $event, $param) { // Intentional: the key is exposed to the browser for direct OMDb lookups. global $JSINFO; $JSINFO["luxtools_omdb_apikey"] = (string) $this->getConf( "omdb_apikey", ); } /** * Serve server-rendered calendar widget HTML for month navigation. * * @param Event $event * @param mixed $param * @return void */ public function handleCalendarWidgetAjax(Event $event, $param) { if ($event->data !== "luxtools_calendar_month") { return; } $event->preventDefault(); $event->stopPropagation(); global $INPUT; $year = (int) $INPUT->int("year"); $month = (int) $INPUT->int("month"); $baseNs = trim((string) $INPUT->str("base")); if ($baseNs === "") { $baseNs = "chronological"; } $size = ChronologicalCalendarWidget::normalizeSize( (string) $INPUT->str("size"), ); $showTimes = ChronologicalCalendarWidget::normalizeShowTimes( $INPUT->str("show_times"), ); if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) { http_status(400); echo "Invalid month"; return; } $this->sendNoStoreHeaders(); $slots = CalendarSlot::loadEnabled($this); $widgetSlots = CalendarSlot::filterWidgetVisible($slots); $indicators = []; $dayEvents = []; if ($size === "large") { $widgetData = CalendarService::monthWidgetData( $widgetSlots, $year, $month, ); $indicators = $widgetData["indicators"]; $dayEvents = $widgetData["events"]; } else { $indicators = CalendarService::monthIndicators( $widgetSlots, $year, $month, ); } $slotColors = []; $slotDisplays = []; foreach ($widgetSlots as $slot) { $color = $slot->getColor(); if ($color !== "") { $slotColors[$slot->getKey()] = $color; } $slotDisplays[$slot->getKey()] = $slot->getDisplay(); } $html = ChronologicalCalendarWidget::render( $year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [ "size" => $size, "showTimes" => $showTimes, "dayEvents" => $dayEvents, ], ); if ($html === "") { http_status(500); echo "Calendar rendering failed"; return; } header("Content-Type: text/html; charset=utf-8"); echo $html; } /** * Auto-link strict ISO dates (YYYY-MM-DD) in rendered XHTML text nodes. * * Excludes content inside tags where links should not be altered. * * @param Event $event * @param mixed $param * @return void */ public function autoLinkChronologicalDates(Event $event, $param) { if (!is_array($event->data)) { return; } $mode = (string) ($event->data[0] ?? ""); if ($mode !== "xhtml") { return; } $doc = $event->data[1] ?? null; if (!is_string($doc) || $doc === "") { return; } if (!preg_match("/\d{4}-\d{2}-\d{2}/", $doc)) { return; } $event->data[1] = ChronologicalDateAutoLinker::linkHtml($doc); } /** * Prefill new chronological day pages with a German date headline. * * @param Event $event * @param mixed $param * @return void */ public function prefillChronologicalDayTemplate(Event $event, $param) { if (!is_array($event->data)) { return; } $id = (string) ($event->data["id"] ?? ""); if ($id === "") { return; } if (function_exists("cleanID")) { $id = (string) cleanID($id); } if ($id === "") { return; } if (!ChronoID::isDayId($id)) { return; } $template = ChronologicalDayTemplate::buildForDayId($id); if ($template === null || $template === "") { return; } $event->data["tpl"] = $template; $event->data["tplfile"] = ""; $event->data["doreplace"] = false; } /** * Append matching date-prefixed photos to chronological day page output. * * @param Event $event * @param mixed $param * @return void */ public function appendChronologicalDayPhotos(Event $event, $param) { if (self::$internalRenderInProgress) { return; } if (!is_array($event->data)) { return; } $mode = (string) ($event->data[0] ?? ""); if ($mode !== "xhtml") { return; } global $ACT; if (!is_string($ACT) || $ACT !== "show") { return; } $doc = $event->data[1] ?? null; if (!is_string($doc)) { return; } if (str_contains($doc, "luxtools-chronological-photos")) { return; } global $ID; $id = is_string($ID) ? $ID : ""; if ($id === "") { return; } if (function_exists("cleanID")) { $id = (string) cleanID($id); } if ($id === "") { return; } $parts = ChronoID::parseDayId($id); if ($parts === null) { return; } if (!function_exists("page_exists") || !page_exists($id)) { return; } $basePath = trim((string) $this->getConf("image_base_path")); if ($basePath === "") { return; } $dateIso = sprintf( "%04d-%02d-%02d", $parts["year"], $parts["month"], $parts["day"], ); if (!$this->hasAnyChronologicalPhotos($dateIso)) { return; } $photosHtml = $this->renderChronologicalPhotosMacro($dateIso); if ($photosHtml === "") { return; } $event->data[1] = $doc . $photosHtml; } /** * Append local calendar events to existing chronological day pages. * * @param Event $event * @param mixed $param * @return void */ public function appendChronologicalDayEvents(Event $event, $param) { static $appendInProgress = false; if ($appendInProgress) { return; } if (self::$internalRenderInProgress) { return; } if (!is_array($event->data)) { return; } $mode = (string) ($event->data[0] ?? ""); if ($mode !== "xhtml") { return; } global $ACT; if (!is_string($ACT) || $ACT !== "show") { return; } $doc = $event->data[1] ?? null; if (!is_string($doc)) { return; } if (str_contains($doc, "luxtools-chronological-events")) { return; } global $ID; $id = is_string($ID) ? $ID : ""; if ($id === "") { return; } if (function_exists("cleanID")) { $id = (string) cleanID($id); } if ($id === "") { return; } $parts = ChronoID::parseDayId($id); if ($parts === null) { return; } if (!function_exists("page_exists") || !page_exists($id)) { return; } $dateIso = sprintf( "%04d-%02d-%02d", $parts["year"], $parts["month"], $parts["day"], ); $appendInProgress = true; try { $eventsHtml = $this->renderChronologicalEventsHtml($dateIso); } finally { $appendInProgress = false; } if ($eventsHtml === "") { return; } $event->data[1] = $doc . $eventsHtml; } /** * Render chronological day photos using existing {{images>...}} syntax. * * @param string $dateIso * @return string */ protected function renderChronologicalPhotosMacro(string $dateIso): string { $syntax = $this->buildChronologicalImagesSyntax($dateIso); if ($syntax === "") { return ""; } if (self::$internalRenderInProgress) { return ""; } self::$internalRenderInProgress = true; try { $info = ["cache" => false]; $instructions = p_get_instructions($syntax); $galleryHtml = (string) p_render("xhtml", $instructions, $info); } finally { self::$internalRenderInProgress = false; } if ($galleryHtml === "") { return ""; } $title = (string) $this->getLang("chronological_photos_title"); if ($title === "") { $title = "Photos"; } return '
' . "

" . hsc($title) . "

" . $galleryHtml . "
"; } /** * Build {{images>...}} syntax for a given day. * * @param string $dateIso * @return string */ protected function buildChronologicalImagesSyntax(string $dateIso): string { $basePath = trim((string) $this->getConf("image_base_path")); if ($basePath === "") { return ""; } $base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath); if (!is_dir($base) || !is_readable($base)) { return ""; } $yearDir = rtrim($base, "/") . "/" . substr($dateIso, 0, 4) . "/"; $targetDir = is_dir($yearDir) && is_readable($yearDir) ? $yearDir : $base; return "{{images>" . $targetDir . $dateIso . "*&recursive=0}}"; } /** * Render a virtual day page for missing chronological day IDs. * * Shows a German date heading and existing day photos (if any) without creating the page. * * @param Event $event * @param mixed $param * @return void */ public function renderVirtualChronologicalDayPage(Event $event, $param) { if (!is_string($event->data) || $event->data !== "show") { return; } global $ID; $id = is_string($ID) ? $ID : ""; if ($id === "") { return; } if (function_exists("cleanID")) { $id = (string) cleanID($id); } if ($id === "") { return; } if (!ChronoID::isDayId($id)) { return; } $this->sendNoStoreHeaders(); if (function_exists("page_exists") && page_exists($id)) { return; } $wikiText = ChronologicalDayTemplate::buildForDayId($id) ?? ""; if ($wikiText === "") { return; } $parts = ChronoID::parseDayId($id); $extraHtml = ""; if ($parts !== null) { $dateIso = sprintf( "%04d-%02d-%02d", $parts["year"], $parts["month"], $parts["day"], ); $eventsHtml = $this->renderChronologicalEventsHtml($dateIso); if ($eventsHtml !== "") { $extraHtml .= $eventsHtml; } if ($this->hasAnyChronologicalPhotos($dateIso)) { $photosHtml = $this->renderChronologicalPhotosMacro($dateIso); if ($photosHtml !== "") { $extraHtml .= $photosHtml; } } } $editUrl = function_exists("wl") ? (string) wl($id, ["do" => "edit"]) : ""; $createLinkHtml = ""; if ($editUrl !== "") { $label = (string) $this->getLang("btn_create"); if ($label === "") { $label = "Create this page"; } $createLinkHtml = '

✎ ' . hsc($label) . "

"; } $info = ["cache" => false]; $instructions = p_get_instructions($wikiText); $html = (string) p_render("xhtml", $instructions, $info); echo $html . $createLinkHtml . $extraHtml; $event->preventDefault(); $event->stopPropagation(); } /** * Check if there is at least one date-prefixed image for the given day. * * @param string $dateIso * @return bool */ protected function hasAnyChronologicalPhotos(string $dateIso): bool { if (!ChronoID::isIsoDate($dateIso)) { return false; } $basePath = trim((string) $this->getConf("image_base_path")); if ($basePath === "") { return false; } $base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath); if (!is_dir($base) || !is_readable($base)) { return false; } $yearDir = rtrim($base, "/") . "/" . substr($dateIso, 0, 4) . "/"; $targetDir = is_dir($yearDir) && is_readable($yearDir) ? $yearDir : $base; $pattern = rtrim($targetDir, "/") . "/" . $dateIso . "*"; $matches = glob($pattern) ?: []; foreach ($matches as $match) { if (!is_file($match)) { continue; } $ext = strtolower(pathinfo($match, PATHINFO_EXTENSION)); if (in_array($ext, ["jpg", "jpeg", "png", "gif", "webp"], true)) { return true; } } return false; } /** * 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 { $slots = CalendarSlot::loadEnabled($this); if ($slots === []) { return ""; } $grouped = CalendarService::eventsForDateGrouped($slots, $dateIso); if ($grouped === []) { return ""; } $html = ""; // Render general events if (isset($grouped["general"])) { $title = (string) $this->getLang("chronological_events_title"); if ($title === "") { $title = "Events"; } $html .= $this->renderEventSection( $grouped["general"], $title, "general", ); } // Render slot2/slot3/slot4 if present foreach (["slot2", "slot3", "slot4"] as $slotKey) { if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) { $label = $slots[$slotKey]->getLabel(); $html .= $this->renderEventSection( $grouped[$slotKey], $label, $slotKey, ); } } return $html; } /** * Render a section of events for a given slot. * * @param CalendarEvent[] $events * @param string $title * @param string $slotKey * @return string */ protected function renderEventSection( array $events, string $title, string $slotKey, ): string { $items = ""; foreach ($events as $event) { $items .= $this->renderEventListItem($event); } if ($items === "") { return ""; } return '
' . "

" . hsc($title) . "

" . "" . "
"; } /** * 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->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 . ""; } $timeHtml = '' . hsc($event->time) . ""; return "" . $timeHtml . ' - ' . $summaryHtml . ""; } /** * 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 (empty($_SERVER["REMOTE_USER"])) { http_status(403); echo json_encode([ "ok" => false, "error" => "Authentication required", ]); return; } $slots = CalendarSlot::loadEnabled($this); $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) { $result[] = [ "key" => $slot->getKey(), "label" => $slot->getLabel(), ]; } 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(); // 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" => 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. * * @param string $dateIso * @return string */ protected function buildChronologicalEventsWiki(string $dateIso): string { $slots = CalendarSlot::loadEnabled($this); if ($slots === []) { return ""; } $events = CalendarService::eventsForDate($slots, $dateIso); if ($events === []) { return ""; } $lines = []; foreach ($events as $event) { $summary = str_replace(["\n", "\r"], " ", $event->summary); if ($event->allDay || $event->time === "") { $lines[] = " * " . $summary; } else { $lines[] = " * " . $event->time . " - " . $summary; } } return implode("\n", $lines); } /** * Add custom toolbar button for code blocks. * * @param Event $event * @param mixed $param * @return void */ public function addToolbarButton(Event $event, $param) { $event->data[] = [ "type" => "format", "title" => $this->getLang("toolbar_code_title"), "icon" => "../../plugins/luxtools/images/code.png", "key" => "C", "open" => "", "sample" => $this->getLang("toolbar_code_sample"), "close" => "", "block" => false, ]; // Date Fix: normalize selected timestamp $event->data[] = [ "type" => "LuxtoolsDatefix", "title" => $this->getLang("toolbar_datefix_title"), "icon" => "../../plugins/luxtools/images/date-fix.svg", "key" => "t", "block" => false, ]; // Date Fix All: normalize all timestamps on page $event->data[] = [ "type" => "LuxtoolsDatefixAll", "title" => $this->getLang("toolbar_datefix_all_title"), "icon" => "../../plugins/luxtools/images/date-fix-all.svg", "block" => false, ]; // Movie Import: fetch movie metadata from OMDb $event->data[] = [ "type" => "LuxtoolsMovieImport", "title" => $this->getLang("toolbar_movie_title"), "icon" => "../../plugins/luxtools/images/movie.svg", "block" => false, ]; } /** * Add admin-only page-tools item to invalidate luxtools caches. * * @param Event $event * @param mixed $param * @return void */ public function addInvalidateCacheMenuItem(Event $event, $param) { if (!is_array($event->data)) { return; } if (($event->data["view"] ?? "") !== "page") { return; } if (!function_exists("auth_isadmin") || !auth_isadmin()) { return; } if (!isset($event->data["items"]) || !is_array($event->data["items"])) { return; } $label = (string) $this->getLang("cache_invalidate_button"); if ($label === "") { $label = "Invalidate Cache"; } $title = (string) $this->getLang("cache_invalidate_button_title"); if ($title === "") { $title = "Invalidate luxtools cache for this page"; } $event->data["items"][] = new InvalidateCache($label, $title); } /** * Handle manual cache invalidation action. * * @param Event $event * @param mixed $param * @return void */ public function handleInvalidateCacheAction(Event $event, $param) { if (!is_string($event->data) || $event->data !== "show") { return; } global $INPUT; if (!$INPUT->bool("luxtools_invalidate_cache")) { return; } global $ID; $id = is_string($ID) ? $ID : ""; if (function_exists("cleanID")) { $id = (string) cleanID($id); } if (!function_exists("auth_isadmin") || !auth_isadmin()) { $message = (string) $this->getLang("cache_invalidate_denied"); if ($message === "") { $message = "Only admins can invalidate cache."; } msg($message, -1); $this->redirectToShow($id); return; } if (!checkSecurityToken()) { $message = (string) $this->getLang("cache_invalidate_badtoken"); if ($message === "") { $message = "Security token mismatch. Please retry."; } msg($message, -1); $this->redirectToShow($id); return; } $result = CacheInvalidation::purgeSelected( $INPUT->bool("luxtools_purge_pagelinks"), $INPUT->bool("luxtools_purge_thumbs"), ); $parts = []; $dokuwikiMsg = (string) $this->getLang("cache_invalidate_success"); if ($dokuwikiMsg === "") { $dokuwikiMsg = "DokuWiki cache invalidated."; } $parts[] = $dokuwikiMsg . " (" . $result["dokuwiki"] . ")"; if ($result["pagelinks"] !== null) { $msg = (string) $this->getLang("cache_purge_pagelinks_success"); if ($msg === "") { $msg = "Pagelinks cache purged."; } $parts[] = $msg . " (" . $result["pagelinks"] . ")"; } if ($result["thumbs"] !== null) { $msg = (string) $this->getLang("cache_purge_thumbs_success"); if ($msg === "") { $msg = "Thumbnail cache purged."; } $parts[] = $msg . " (" . $result["thumbs"] . ")"; } msg(implode(" ", $parts), 1); $this->redirectToShow($id); } /** * Redirect to normal show view for the given page. * * @param string $id * @return void */ protected function redirectToShow(string $id): void { $params = ["do" => "show"]; send_redirect(wl($id, $params, true, "&")); } /** * Send no-store cache headers for highly dynamic responses. * * @return void */ protected function sendNoStoreHeaders(): void { if (headers_sent()) { return; } header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); header("Pragma: no-cache"); header("Expires: 0"); } }