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,
"handleMaintenanceTaskAction",
);
$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,
];
}
}
/**
* 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 maintenance tasks
if (isset($grouped["maintenance"])) {
$title = (string) $this->getLang("chronological_maintenance_title");
if ($title === "") {
$title = "Tasks";
}
$html .= $this->renderMaintenanceSection(
$grouped["maintenance"],
$title,
$dateIso,
);
}
// Render slot3/slot4 if present
foreach (["slot3", "slot4"] as $slotKey) {
if (isset($grouped[$slotKey]) && isset($slots[$slotKey])) {
$label = $slots[$slotKey]->getLabel();
$html .= $this->renderEventSection(
$grouped[$slotKey],
$label,
$slotKey,
);
}
}
return $html;
}
/**
* Render a section of events for a given slot.
*
* @param CalendarEvent[] $events
* @param string $title
* @param string $slotKey
* @return string
*/
protected function renderEventSection(
array $events,
string $title,
string $slotKey,
): string {
$items = "";
foreach ($events as $event) {
$items .= $this->renderEventListItem($event);
}
if ($items === "") {
return "";
}
return '";
}
/**
* Render a maintenance task section with completion buttons.
*
* @param CalendarEvent[] $events
* @param string $title
* @param string $dateIso
* @return string
*/
protected function renderMaintenanceSection(
array $events,
string $title,
string $dateIso,
): string {
$items = "";
$ajaxUrl = defined("DOKU_BASE")
? (string) DOKU_BASE . "lib/exe/ajax.php"
: "lib/exe/ajax.php";
foreach ($events as $event) {
$items .= $this->renderMaintenanceListItem($event, $ajaxUrl);
}
if ($items === "") {
return "";
}
$secToken = function_exists("getSecurityToken")
? getSecurityToken()
: "";
return '' .
"
" .
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 .
"";
}
/**
* Render a maintenance task as a list item with completion button.
*
* @param CalendarEvent $event
* @param string $ajaxUrl
* @return string
*/
protected function renderMaintenanceListItem(
CalendarEvent $event,
string $ajaxUrl,
): string {
$isCompleted = $event->isCompleted();
$classes = "luxtools-maintenance-task";
if ($isCompleted) {
$classes .= " luxtools-task-completed";
}
$summaryHtml = hsc($event->summary);
// Data attributes for popup and completion
$dataAttrs = ' data-luxtools-event="1"';
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
if ($event->endIso !== "") {
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
}
if ($event->location !== "") {
$dataAttrs .=
' data-event-location="' . hsc($event->location) . '"';
}
if ($event->description !== "") {
$dataAttrs .=
' data-event-description="' . hsc($event->description) . '"';
}
$dataAttrs .=
' data-event-allday="' . ($event->allDay ? "1" : "0") . '"';
$dataAttrs .= ' data-event-slot="maintenance"';
$dataAttrs .= ' data-task-uid="' . hsc($event->uid) . '"';
$dataAttrs .= ' data-task-date="' . hsc($event->dateIso) . '"';
$dataAttrs .=
' data-task-recurrence="' . hsc($event->recurrenceId) . '"';
$dataAttrs .= ' data-task-status="' . hsc($event->status) . '"';
$buttonLabel = $isCompleted
? (string) $this->getLang("maintenance_task_reopen")
: (string) $this->getLang("maintenance_task_complete");
if ($buttonLabel === "") {
$buttonLabel = $isCompleted ? "Reopen" : "Complete";
}
$buttonAction = $isCompleted ? "reopen" : "complete";
$buttonHtml =
'";
$timeHtml = "";
if (!$event->allDay && $event->time !== "") {
$timeHtml =
'' .
hsc($event->time) .
" - ";
}
return '" .
$timeHtml .
'' .
$summaryHtml .
" " .
$buttonHtml .
"";
}
/**
* Handle AJAX requests for marking maintenance tasks complete/reopen.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function handleMaintenanceTaskAction(Event $event, $param)
{
if ($event->data !== "luxtools_maintenance_task") {
return;
}
$event->preventDefault();
$event->stopPropagation();
header("Content-Type: application/json; charset=utf-8");
$this->sendNoStoreHeaders();
global $INPUT;
// Verify security token
if (!checkSecurityToken()) {
http_status(403);
echo json_encode([
"ok" => false,
"error" => "Security token mismatch",
]);
return;
}
$action = $INPUT->str("action"); // 'complete' or 'reopen'
$uid = $INPUT->str("uid");
$dateIso = $INPUT->str("date");
$recurrence = $INPUT->str("recurrence");
if (!in_array($action, ["complete", "reopen"], true)) {
http_status(400);
echo json_encode(["ok" => false, "error" => "Invalid action"]);
return;
}
if ($uid === "" || !ChronoID::isIsoDate($dateIso)) {
http_status(400);
echo json_encode(["ok" => false, "error" => "Missing uid or date"]);
return;
}
$slots = CalendarSlot::loadAll($this);
$maintenanceSlot = $slots["maintenance"] ?? null;
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
http_status(400);
echo json_encode([
"ok" => false,
"error" => "Maintenance calendar not configured",
]);
return;
}
$newStatus = $action === "complete" ? "COMPLETED" : "TODO";
// Update local ICS file
$localOk = false;
$file = $maintenanceSlot->getFile();
if ($file !== "" && is_file($file)) {
$localOk = IcsWriter::updateEventStatus(
$file,
$uid,
$recurrence,
$newStatus,
$dateIso,
);
}
if (!$localOk) {
http_status(500);
echo json_encode([
"ok" => false,
"error" => $this->getLang("maintenance_complete_error"),
]);
return;
}
// Clear caches so next render picks up changes
CalendarService::clearCache();
// Remote CalDAV write-back if configured
$remoteOk = true;
$remoteError = "";
if ($maintenanceSlot->hasRemoteSource()) {
try {
$caldavResult = CalDavClient::updateEventStatus(
$maintenanceSlot->getCaldavUrl(),
$maintenanceSlot->getUsername(),
$maintenanceSlot->getPassword(),
$uid,
$recurrence,
$newStatus,
$dateIso,
);
if ($caldavResult !== "") {
$remoteOk = false;
$remoteError =
$this->getLang("maintenance_remote_write_failed") .
": " .
$caldavResult;
}
} catch (Throwable $e) {
$remoteOk = false;
$remoteError =
$this->getLang("maintenance_remote_write_failed") .
": " .
$e->getMessage();
}
}
$msg =
$action === "complete"
? $this->getLang("maintenance_complete_success")
: $this->getLang("maintenance_reopen_success");
echo json_encode([
"ok" => true,
"message" => $msg,
"remoteOk" => $remoteOk,
"remoteError" => $remoteError,
]);
}
/**
* Handle AJAX requests for manual calendar sync.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function handleCalendarSyncAction(Event $event, $param)
{
if ($event->data !== "luxtools_calendar_sync") {
return;
}
$event->preventDefault();
$event->stopPropagation();
header("Content-Type: application/json; charset=utf-8");
$this->sendNoStoreHeaders();
global $INPUT;
if (!checkSecurityToken()) {
http_status(403);
echo json_encode([
"ok" => false,
"error" => "Security token mismatch",
]);
return;
}
if (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");
}
}