Files
luxtools-plugin/action.php
2026-02-16 13:39:26 +01:00

582 lines
18 KiB
PHP

<?php
use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\Extension\EventHandler;
use dokuwiki\plugin\luxtools\ChronoID;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
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(
"CSS_STYLES_INCLUDED",
"BEFORE",
$this,
"addTemporaryInputStyles",
);
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleCalendarWidgetAjax",
);
$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",
"main.js",
];
foreach ($scripts as $script) {
$event->data["script"][] = [
"type" => "text/javascript",
"src" => $base . $script,
];
}
}
/**
* 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';
}
if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) {
http_status(400);
echo 'Invalid month';
return;
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs);
if ($html === '') {
http_status(500);
echo 'Calendar rendering failed';
return;
}
header('Content-Type: text/html; charset=utf-8');
echo $html;
}
/**
* Include temporary global input styling via css.php so @ini_* placeholders resolve.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function addTemporaryInputStyles(Event $event, $param)
{
if (!isset($event->data['mediatype']) || $event->data['mediatype'] !== 'screen') {
return;
}
if (!isset($event->data['files']) || !is_array($event->data['files'])) {
return;
}
$plugin = $this->getPluginName();
$event->data['files'][DOKU_PLUGIN . $plugin . '/temp-input-colors.css'] = DOKU_BASE . 'lib/plugins/' . $plugin . '/';
}
/**
* 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;
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.
*
* @param string $dateIso
* @return string
*/
protected function renderChronologicalEventsHtml(string $dateIso): string
{
$icsConfig = (string)$this->getConf('calendar_ics_files');
if (trim($icsConfig) === '') return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
if ($events === []) return '';
$title = (string)$this->getLang('chronological_events_title');
if ($title === '') $title = 'Events';
$items = '';
foreach ($events as $entry) {
$summary = trim((string)($entry['summary'] ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
$time = trim((string)($entry['time'] ?? ''));
$startIso = trim((string)($entry['startIso'] ?? ''));
$isAllDay = (bool)($entry['allDay'] ?? false);
if ($isAllDay || $time === '') {
$items .= '<li>' . hsc($summary) . '</li>';
} else {
$timeHtml = '<span class="luxtools-event-time"';
if ($startIso !== '') {
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
}
$timeHtml .= '>' . hsc($time) . '</span>';
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
}
}
if ($items === '') return '';
$html = '<ul>' . $items . '</ul>';
return '<div class="luxtools-plugin luxtools-chronological-events">'
. '<h2>' . hsc($title) . '</h2>'
. $html
. '</div>';
}
/**
* Build wiki bullet list for local calendar events.
*
* @param string $dateIso
* @return string
*/
protected function buildChronologicalEventsWiki(string $dateIso): string
{
$icsConfig = (string)$this->getConf('calendar_ics_files');
if (trim($icsConfig) === '') return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
if ($events === []) return '';
$lines = [];
foreach ($events as $event) {
$summary = trim((string)($event['summary'] ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
$summary = str_replace(["\n", "\r"], ' ', $summary);
$time = trim((string)($event['time'] ?? ''));
if ((bool)($event['allDay'] ?? false) || $time === '') {
$lines[] = ' * ' . $summary;
} else {
$lines[] = ' * ' . $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,
];
}
}