1883 lines
53 KiB
PHP
1883 lines
53 KiB
PHP
<?php
|
|
|
|
use dokuwiki\Extension\ActionPlugin;
|
|
use dokuwiki\Extension\Event;
|
|
use dokuwiki\Extension\EventHandler;
|
|
use dokuwiki\plugin\luxtools\CacheInvalidation;
|
|
use dokuwiki\plugin\luxtools\CalDavClient;
|
|
use dokuwiki\plugin\luxtools\CalendarEvent;
|
|
use dokuwiki\plugin\luxtools\CalendarService;
|
|
use dokuwiki\plugin\luxtools\CalendarSlot;
|
|
use dokuwiki\plugin\luxtools\CalendarSyncService;
|
|
use dokuwiki\plugin\luxtools\ChronoID;
|
|
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
|
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
|
|
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
|
|
use dokuwiki\plugin\luxtools\IcsWriter;
|
|
use dokuwiki\plugin\luxtools\MenuItem\InvalidateCache;
|
|
require_once __DIR__ . "/autoload.php";
|
|
|
|
/**
|
|
* luxtools action plugin: register JS assets.
|
|
*/
|
|
class action_plugin_luxtools extends ActionPlugin
|
|
{
|
|
/** @var bool Guard to prevent postprocess appenders during internal renders */
|
|
protected static $internalRenderInProgress = false;
|
|
|
|
/** @inheritdoc */
|
|
public function register(EventHandler $controller)
|
|
{
|
|
$controller->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 '<div class="luxtools-plugin luxtools-chronological-photos">' .
|
|
"<h2>" .
|
|
hsc($title) .
|
|
"</h2>" .
|
|
$galleryHtml .
|
|
"</div>";
|
|
}
|
|
|
|
/**
|
|
* 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 =
|
|
'<p><a href="' .
|
|
hsc($editUrl) .
|
|
'">✎ ' .
|
|
hsc($label) .
|
|
"</a></p>";
|
|
}
|
|
|
|
$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 '<div class="luxtools-plugin luxtools-chronological-events luxtools-slot-' .
|
|
hsc($slotKey) .
|
|
'">' .
|
|
"<h2>" .
|
|
hsc($title) .
|
|
"</h2>" .
|
|
"<ul>" .
|
|
$items .
|
|
"</ul>" .
|
|
"</div>";
|
|
}
|
|
|
|
/**
|
|
* Render a maintenance task section with completion buttons.
|
|
*
|
|
* @param CalendarEvent[] $events
|
|
* @param string $title
|
|
* @param string $dateIso
|
|
* @return string
|
|
*/
|
|
protected function renderMaintenanceSection(
|
|
array $events,
|
|
string $title,
|
|
string $dateIso,
|
|
): string {
|
|
$items = "";
|
|
$ajaxUrl = defined("DOKU_BASE")
|
|
? (string) DOKU_BASE . "lib/exe/ajax.php"
|
|
: "lib/exe/ajax.php";
|
|
|
|
foreach ($events as $event) {
|
|
$items .= $this->renderMaintenanceListItem($event, $ajaxUrl);
|
|
}
|
|
if ($items === "") {
|
|
return "";
|
|
}
|
|
|
|
$secToken = function_exists("getSecurityToken")
|
|
? getSecurityToken()
|
|
: "";
|
|
|
|
return '<div class="luxtools-plugin luxtools-chronological-events luxtools-chronological-maintenance"' .
|
|
' data-luxtools-ajax-url="' .
|
|
hsc($ajaxUrl) .
|
|
'"' .
|
|
' data-luxtools-sectok="' .
|
|
hsc($secToken) .
|
|
'">' .
|
|
"<h2>" .
|
|
hsc($title) .
|
|
"</h2>" .
|
|
"<ul>" .
|
|
$items .
|
|
"</ul>" .
|
|
"</div>";
|
|
}
|
|
|
|
/**
|
|
* Render a single event as a list item with popup data attributes.
|
|
*
|
|
* @param CalendarEvent $event
|
|
* @return string
|
|
*/
|
|
protected function renderEventListItem(CalendarEvent $event): string
|
|
{
|
|
$summaryHtml = hsc($event->summary);
|
|
|
|
// Build event detail data attributes for popup
|
|
$dataAttrs = ' data-luxtools-event="1"';
|
|
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
|
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
|
if ($event->endIso !== "") {
|
|
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
|
}
|
|
if ($event->location !== "") {
|
|
$dataAttrs .=
|
|
' data-event-location="' . hsc($event->location) . '"';
|
|
}
|
|
if ($event->description !== "") {
|
|
$dataAttrs .=
|
|
' data-event-description="' . hsc($event->description) . '"';
|
|
}
|
|
$dataAttrs .=
|
|
' data-event-allday="' . ($event->allDay ? "1" : "0") . '"';
|
|
$dataAttrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
|
|
if ($event->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>";
|
|
}
|
|
|
|
$timeHtml =
|
|
'<span class="luxtools-event-time" data-luxtools-start="' .
|
|
hsc($event->startIso) .
|
|
'">' .
|
|
hsc($event->time) .
|
|
"</span>";
|
|
return "<li" .
|
|
$dataAttrs .
|
|
">" .
|
|
$timeHtml .
|
|
' - <span class="luxtools-event-summary">' .
|
|
$summaryHtml .
|
|
"</span></li>";
|
|
}
|
|
|
|
/**
|
|
* Render a maintenance task as a list item with completion button.
|
|
*
|
|
* @param CalendarEvent $event
|
|
* @param string $ajaxUrl
|
|
* @return string
|
|
*/
|
|
protected function renderMaintenanceListItem(
|
|
CalendarEvent $event,
|
|
string $ajaxUrl,
|
|
): string {
|
|
$isCompleted = $event->isCompleted();
|
|
$classes = "luxtools-maintenance-task";
|
|
if ($isCompleted) {
|
|
$classes .= " luxtools-task-completed";
|
|
}
|
|
|
|
$summaryHtml = hsc($event->summary);
|
|
|
|
// Data attributes for popup and completion
|
|
$dataAttrs = ' data-luxtools-event="1"';
|
|
$dataAttrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
|
$dataAttrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
|
if ($event->endIso !== "") {
|
|
$dataAttrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
|
}
|
|
if ($event->location !== "") {
|
|
$dataAttrs .=
|
|
' data-event-location="' . hsc($event->location) . '"';
|
|
}
|
|
if ($event->description !== "") {
|
|
$dataAttrs .=
|
|
' data-event-description="' . hsc($event->description) . '"';
|
|
}
|
|
$dataAttrs .=
|
|
' data-event-allday="' . ($event->allDay ? "1" : "0") . '"';
|
|
$dataAttrs .= ' data-event-slot="maintenance"';
|
|
$dataAttrs .= ' data-task-uid="' . hsc($event->uid) . '"';
|
|
$dataAttrs .= ' data-task-date="' . hsc($event->dateIso) . '"';
|
|
$dataAttrs .=
|
|
' data-task-recurrence="' . hsc($event->recurrenceId) . '"';
|
|
$dataAttrs .= ' data-task-status="' . hsc($event->status) . '"';
|
|
|
|
$buttonLabel = $isCompleted
|
|
? (string) $this->getLang("maintenance_task_reopen")
|
|
: (string) $this->getLang("maintenance_task_complete");
|
|
if ($buttonLabel === "") {
|
|
$buttonLabel = $isCompleted ? "Reopen" : "Complete";
|
|
}
|
|
$buttonAction = $isCompleted ? "reopen" : "complete";
|
|
|
|
$buttonHtml =
|
|
'<button type="button" class="luxtools-task-action" data-action="' .
|
|
hsc($buttonAction) .
|
|
'">' .
|
|
hsc($buttonLabel) .
|
|
"</button>";
|
|
|
|
$timeHtml = "";
|
|
if (!$event->allDay && $event->time !== "") {
|
|
$timeHtml =
|
|
'<span class="luxtools-event-time" data-luxtools-start="' .
|
|
hsc($event->startIso) .
|
|
'">' .
|
|
hsc($event->time) .
|
|
"</span> - ";
|
|
}
|
|
|
|
return '<li class="' .
|
|
hsc($classes) .
|
|
'"' .
|
|
$dataAttrs .
|
|
">" .
|
|
$timeHtml .
|
|
'<span class="luxtools-event-summary">' .
|
|
$summaryHtml .
|
|
"</span> " .
|
|
$buttonHtml .
|
|
"</li>";
|
|
}
|
|
|
|
/**
|
|
* Handle AJAX requests for marking maintenance tasks complete/reopen.
|
|
*
|
|
* @param Event $event
|
|
* @param mixed $param
|
|
* @return void
|
|
*/
|
|
public function handleMaintenanceTaskAction(Event $event, $param)
|
|
{
|
|
if ($event->data !== "luxtools_maintenance_task") {
|
|
return;
|
|
}
|
|
|
|
$event->preventDefault();
|
|
$event->stopPropagation();
|
|
|
|
header("Content-Type: application/json; charset=utf-8");
|
|
$this->sendNoStoreHeaders();
|
|
|
|
global $INPUT;
|
|
|
|
// Verify security token
|
|
if (!checkSecurityToken()) {
|
|
http_status(403);
|
|
echo json_encode([
|
|
"ok" => false,
|
|
"error" => "Security token mismatch",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$action = $INPUT->str("action"); // 'complete' or 'reopen'
|
|
$uid = $INPUT->str("uid");
|
|
$dateIso = $INPUT->str("date");
|
|
$recurrence = $INPUT->str("recurrence");
|
|
|
|
if (!in_array($action, ["complete", "reopen"], true)) {
|
|
http_status(400);
|
|
echo json_encode(["ok" => false, "error" => "Invalid action"]);
|
|
return;
|
|
}
|
|
if ($uid === "" || !ChronoID::isIsoDate($dateIso)) {
|
|
http_status(400);
|
|
echo json_encode(["ok" => false, "error" => "Missing uid or date"]);
|
|
return;
|
|
}
|
|
|
|
$slots = CalendarSlot::loadAll($this);
|
|
$maintenanceSlot = $slots["maintenance"] ?? null;
|
|
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
|
|
http_status(400);
|
|
echo json_encode([
|
|
"ok" => false,
|
|
"error" => "Maintenance calendar not configured",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$newStatus = $action === "complete" ? "COMPLETED" : "TODO";
|
|
|
|
// Update local ICS file
|
|
$localOk = false;
|
|
$file = $maintenanceSlot->getFile();
|
|
if ($file !== "" && is_file($file)) {
|
|
$localOk = IcsWriter::updateEventStatus(
|
|
$file,
|
|
$uid,
|
|
$recurrence,
|
|
$newStatus,
|
|
$dateIso,
|
|
);
|
|
}
|
|
|
|
if (!$localOk) {
|
|
http_status(500);
|
|
echo json_encode([
|
|
"ok" => false,
|
|
"error" => $this->getLang("maintenance_complete_error"),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
// Clear caches so next render picks up changes
|
|
CalendarService::clearCache();
|
|
|
|
// Remote CalDAV write-back if configured
|
|
$remoteOk = true;
|
|
$remoteError = "";
|
|
if ($maintenanceSlot->hasRemoteSource()) {
|
|
try {
|
|
$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" => "<code>",
|
|
"sample" => $this->getLang("toolbar_code_sample"),
|
|
"close" => "</code>",
|
|
"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");
|
|
}
|
|
}
|