Files
luxtools-plugin/action.php
2026-03-17 12:36:12 +01:00

1041 lines
34 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\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(
"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(
"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",
"event-popup.js",
"movie-import.js",
"main.js",
];
foreach ($scripts as $script) {
$event->data["script"][] = [
"type" => "text/javascript",
"src" => $base . $script,
];
}
// Pass OMDb API key to client-side JavaScript.
// 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->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 (!function_exists('auth_isadmin') || !auth_isadmin()) {
http_status(403);
echo json_encode(['ok' => false, 'error' => 'Admin access required']);
return;
}
$slots = CalendarSlot::loadEnabled($this);
$results = [];
$hasErrors = false;
foreach ($slots as $slot) {
if (!$slot->hasRemoteSource()) continue;
$ok = CalDavClient::syncSlot($slot);
$results[$slot->getKey()] = $ok;
if (!$ok) $hasErrors = true;
}
CalendarService::clearCache();
$msg = $hasErrors
? $this->getLang('calendar_sync_partial')
: $this->getLang('calendar_sync_success');
echo json_encode([
'ok' => !$hasErrors,
'message' => $msg,
'results' => $results,
]);
}
/**
* 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');
}
}