diff --git a/README.md b/README.md
index 6845193..0cbade0 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ luxtools provides DokuWiki syntax that:
- Renders an image thumbnail gallery (with lightbox)
- Provides "open this folder/path" links for local workflows
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
+- Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias
It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used
to deliver files and generate cached thumbnails.
@@ -141,6 +142,10 @@ Key settings:
URL of a local client service used by `{{open>...}}` and directory links.
See luxtools-client.
+- **pagelink_search_depth**
+ Maximum directory depth for `.pagelink` discovery under each configured root.
+ `0` means only the root directory itself is checked.
+
### Template style settings
The `{{open>...}}` links and directory “open” links use a dedicated color
@@ -206,6 +211,27 @@ Supported input examples include:
- `2026-01-30 13:45`
- `2026-01-30T13:45:00`
+### 0.2) Editor toolbar: Page Link
+
+The **Page Link** toolbar button creates a page-scoped UUID and stores it in
+page metadata. This UUID is used to link the page to a folder that contains
+a `.pagelink` file with the same UUID.
+
+Workflow:
+
+1. Click **Page Link** in the editor toolbar to create the UUID.
+2. View the page and copy the UUID from the “Not linked: Copy ID” status.
+3. Create a `.pagelink` file in the target folder (within your configured
+ `paths` roots) and paste the UUID into that file.
+
+Once linked, you can use `blobs/` as an alias in luxtools syntax on that page,
+for example:
+
+```
+{{images>blobs/*.png}}
+{{directory>blobs/&recursive=1}}
+```
+
### 1) List files by glob pattern
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
diff --git a/action.php b/action.php
index 804ca51..17b85b5 100644
--- a/action.php
+++ b/action.php
@@ -3,6 +3,9 @@
use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\Extension\EventHandler;
+use dokuwiki\plugin\luxtools\PageLink;
+
+require_once(__DIR__ . '/autoload.php');
/**
* luxtools action plugin: register JS assets.
@@ -18,6 +21,12 @@ class action_plugin_luxtools extends ActionPlugin
$this,
"addScripts",
);
+ $controller->register_hook(
+ "TPL_CONTENT_DISPLAY",
+ "BEFORE",
+ $this,
+ "addPageLinkStatus",
+ );
$controller->register_hook(
"CSS_STYLES_INCLUDED",
"BEFORE",
@@ -49,6 +58,7 @@ class action_plugin_luxtools extends ActionPlugin
"open-service.js",
"scratchpads.js",
"date-fix.js",
+ "page-link.js",
"linkfavicon.js",
"main.js",
];
@@ -118,5 +128,80 @@ class action_plugin_luxtools extends ActionPlugin
"icon" => "../../plugins/luxtools/images/date-fix-all.svg",
"block" => false,
];
+
+ // Page Link: create a page-scoped UUID for .pagelink linking
+ $event->data[] = [
+ "type" => "LuxtoolsPageLink",
+ "title" => $this->getLang("toolbar_pagelink_title"),
+ "icon" => "../../plugins/luxtools/images/pagelink.svg",
+ "block" => false,
+ ];
+ }
+
+ /**
+ * Inject page link status above the page content.
+ *
+ * @param Event $event
+ * @param mixed $param
+ * @return void
+ */
+ public function addPageLinkStatus(Event $event, $param)
+ {
+ global $ACT, $ID;
+
+ if (!is_string($ACT) || $ACT !== 'show') {
+ return;
+ }
+
+ if (!is_string($ID) || $ID === '') {
+ return;
+ }
+
+ $pageId = $ID;
+ if (function_exists('cleanID')) {
+ $pageId = (string)cleanID($pageId);
+ }
+ if ($pageId === '') return;
+
+ $pathConfig = (string)$this->getConf('paths');
+ $depth = (int)$this->getConf('pagelink_search_depth');
+ if ($depth < 0) $depth = 0;
+
+ $pageLink = new PageLink($pathConfig, $depth);
+ $uuid = $pageLink->getPageUuid($pageId);
+ if ($uuid === null) return;
+
+ $linkInfo = $pageLink->resolveUuid($uuid);
+ $folder = $linkInfo['folder'] ?? null;
+ $multiple = !empty($linkInfo['multiple']);
+
+ $statusText = '';
+ $copyable = false;
+ $title = '';
+ if (is_string($folder) && $folder !== '') {
+ $trimmed = rtrim($folder, '/\\');
+ $statusText = basename($trimmed);
+ $title = $trimmed;
+ } else {
+ $statusText = (string)$this->getLang('pagelink_unlinked');
+ $copyable = true;
+ }
+
+ $warning = '';
+ if ($multiple) {
+ $warning = '⚠';
+ }
+
+ $html = ''
+ . hsc($statusText)
+ . $warning
+ . '';
+
+ $event->data = $html . $event->data;
}
}
diff --git a/admin/main.php b/admin/main.php
index 000b741..c1ae652 100644
--- a/admin/main.php
+++ b/admin/main.php
@@ -28,6 +28,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'thumb_placeholder',
'gallery_thumb_scale',
'open_service_url',
+ 'pagelink_search_depth',
];
public function getMenuText($language)
@@ -86,6 +87,10 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
$newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale');
$newConf['open_service_url'] = $INPUT->str('open_service_url');
+ $depth = (int)$INPUT->int('pagelink_search_depth');
+ if ($depth < 0) $depth = 0;
+ $newConf['pagelink_search_depth'] = $depth;
+
if ($this->savePluginLocalConf($newConf)) {
msg($this->getLang('saved'), 1);
} else {
@@ -223,6 +228,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '';
echo '
';
+ // pagelink_search_depth
+ echo '
';
+
echo '';
echo '';
diff --git a/conf/default.php b/conf/default.php
index a91607a..e1e1812 100644
--- a/conf/default.php
+++ b/conf/default.php
@@ -34,6 +34,9 @@ $conf['gallery_thumb_scale'] = 1;
// Local client service used by {{open>...}}.
$conf['open_service_url'] = 'http://127.0.0.1:8765';
+// Maximum depth when searching for .pagelink files under allowed roots.
+$conf['pagelink_search_depth'] = 3;
+
// Image syntax defaults
$conf['default_image_width'] = 250;
$conf['default_image_align'] = 'right'; // left|right|center
diff --git a/images/pagelink.svg b/images/pagelink.svg
new file mode 100644
index 0000000..c5b2918
--- /dev/null
+++ b/images/pagelink.svg
@@ -0,0 +1,5 @@
+
diff --git a/js/page-link.js b/js/page-link.js
new file mode 100644
index 0000000..e4023a6
--- /dev/null
+++ b/js/page-link.js
@@ -0,0 +1,136 @@
+/* global window, document */
+
+(function () {
+ 'use strict';
+
+ function getSectok() {
+ try {
+ if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
+ } catch (e) {}
+
+ try {
+ var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
+ if (inp && inp.value) return String(inp.value);
+ } catch (e2) {}
+
+ return '';
+ }
+
+ function getPageId() {
+ try {
+ if (window.JSINFO && window.JSINFO.id) return String(window.JSINFO.id);
+ } catch (e) {}
+
+ try {
+ var input = document.querySelector('input[name="id"]');
+ if (input && input.value) return String(input.value);
+ } catch (e2) {}
+
+ return '';
+ }
+
+ function getBaseUrl() {
+ try {
+ if (window.DOKU_BASE) return String(window.DOKU_BASE);
+ } catch (e) {}
+
+ try {
+ if (window.JSINFO && window.JSINFO.base) return String(window.JSINFO.base);
+ } catch (e2) {}
+
+ return '/';
+ }
+
+ function ensurePageLink() {
+ var pageId = getPageId();
+ if (!pageId) return false;
+
+ var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
+
+ var params = new window.URLSearchParams();
+ params.set('cmd', 'ensure');
+ params.set('id', pageId);
+ params.set('sectok', getSectok());
+
+ window.fetch(endpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
+ body: params.toString()
+ }).then(function (res) {
+ return res.json().catch(function () { return null; }).then(function (body) {
+ if (!res.ok || !body || body.ok !== true) {
+ throw new Error('request failed');
+ }
+ return body;
+ });
+ }).catch(function (err) {
+ if (window.console && window.console.warn) {
+ window.console.warn('PageLink creation failed:', err);
+ }
+ });
+
+ return false;
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ if (window.navigator && window.navigator.clipboard && window.navigator.clipboard.writeText) {
+ window.navigator.clipboard.writeText(text).catch(function () {});
+ return;
+ }
+
+ try {
+ var textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', 'readonly');
+ textarea.style.position = 'absolute';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ try {
+ document.execCommand('copy');
+ } catch (e) {}
+ document.body.removeChild(textarea);
+ } catch (e2) {}
+ }
+
+ function attachStatus() {
+ var status = document.querySelector('.luxtools-pagelink-status[data-luxtools-pagelink="1"]');
+ if (!status) return;
+
+ var pageTitle = document.querySelector('#dokuwiki__content h1')
+ || document.querySelector('.pageId')
+ || document.querySelector('h1');
+
+ if (pageTitle && pageTitle.appendChild) {
+ status.classList.add('is-inline');
+ pageTitle.appendChild(status);
+ }
+
+ var copy = String(status.getAttribute('data-copy') || '') === '1';
+ if (copy) {
+ status.setAttribute('role', 'button');
+ status.setAttribute('tabindex', '0');
+ status.addEventListener('click', function (e) {
+ e.preventDefault();
+ copyToClipboard(String(status.getAttribute('data-uuid') || '').trim());
+ });
+ status.addEventListener('keydown', function (e) {
+ if (!e || (e.key !== 'Enter' && e.key !== ' ')) return;
+ e.preventDefault();
+ copyToClipboard(String(status.getAttribute('data-uuid') || '').trim());
+ });
+ }
+ }
+
+ window.addBtnActionLuxtoolsPageLink = function ($btn, props, edid) {
+ $btn.on('click', function () {
+ ensurePageLink();
+ return false;
+ });
+ return 'luxtools-pagelink';
+ };
+
+ document.addEventListener('DOMContentLoaded', attachStatus, false);
+})();
diff --git a/lang/de/lang.php b/lang/de/lang.php
index dd7a657..f63fe0f 100644
--- a/lang/de/lang.php
+++ b/lang/de/lang.php
@@ -62,6 +62,8 @@ $lang["gallery_thumb_scale"] =
"Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).";
$lang["open_service_url"] =
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
+$lang["pagelink_search_depth"] =
+ "Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
$lang["scratchpad_edit"] = "Scratchpad bearbeiten";
$lang["scratchpad_save"] = "Speichern";
@@ -73,3 +75,8 @@ $lang["scratchpad_err_unreadable"] = "Scratchpad-Datei ist nicht lesbar";
$lang["toolbar_code_title"] = "Code-Block";
$lang["toolbar_code_sample"] = "Ihr Code hier";
+$lang["toolbar_datefix_title"] = "Datums-Fix";
+$lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)";
+$lang["toolbar_pagelink_title"] = "Seiten-Link";
+$lang["pagelink_unlinked"] = "Nicht verknüpft: ID kopieren";
+$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
diff --git a/lang/de/settings.php b/lang/de/settings.php
index 0eb9e73..d5b1d13 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -26,3 +26,5 @@ $lang["thumb_placeholder"] = "MediaManager-ID fuer den Platzhalter der Galerie-T
$lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erzeugt schaerfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150x150).";
$lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765).";
+
+$lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root).";
diff --git a/lang/en/lang.php b/lang/en/lang.php
index 9c42cb7..d8f1c3c 100644
--- a/lang/en/lang.php
+++ b/lang/en/lang.php
@@ -62,6 +62,8 @@ $lang["gallery_thumb_scale"] =
"Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).";
$lang["open_service_url"] =
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
+$lang["pagelink_search_depth"] =
+ "Maximum directory depth for .pagelink search (0 = only root).";
$lang["scratchpad_edit"] = "Edit scratchpad";
$lang["scratchpad_save"] = "Save";
@@ -75,3 +77,6 @@ $lang["toolbar_code_title"] = "Code Block";
$lang["toolbar_code_sample"] = "your code here";
$lang["toolbar_datefix_title"] = "Date Fix";
$lang["toolbar_datefix_all_title"] = "Date Fix (All)";
+$lang["toolbar_pagelink_title"] = "Page Link";
+$lang["pagelink_unlinked"] = "Not linked: Copy ID";
+$lang["pagelink_multi_warning"] = "Multiple folders linked";
diff --git a/lang/en/settings.php b/lang/en/settings.php
index a3c7507..cdc04a0 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -26,3 +26,5 @@ $lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placehol
$lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).';
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).';
+
+$lang['pagelink_search_depth'] = 'Maximum directory depth for .pagelink search (0 = only root).';
diff --git a/pagelink.php b/pagelink.php
new file mode 100644
index 0000000..5faa4bf
--- /dev/null
+++ b/pagelink.php
@@ -0,0 +1,86 @@
+ false, 'error' => 'plugin disabled']);
+ exit;
+}
+
+/**
+ * Send a JSON response.
+ *
+ * @param int $status
+ * @param array $payload
+ * @return void
+ */
+function luxtools_pagelink_json(int $status, array $payload): void
+{
+ http_status($status);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store, no-cache, must-revalidate');
+ header('Pragma: no-cache');
+ echo json_encode($payload);
+ exit;
+}
+
+$cmd = (string)$INPUT->str('cmd');
+$pageId = (string)$INPUT->str('id');
+if (function_exists('cleanID')) {
+ $pageId = (string)cleanID($pageId);
+}
+
+if ($cmd === '' || $pageId === '') {
+ luxtools_pagelink_json(400, ['ok' => false, 'error' => 'missing parameters']);
+}
+
+if (!function_exists('auth_quickaclcheck') || !defined('AUTH_EDIT')) {
+ luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
+}
+
+if (auth_quickaclcheck($pageId) < AUTH_EDIT) {
+ luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
+}
+
+if ($cmd === 'ensure') {
+ if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
+ luxtools_pagelink_json(405, ['ok' => false, 'error' => 'method not allowed']);
+ }
+
+ if (!checkSecurityToken()) {
+ luxtools_pagelink_json(403, ['ok' => false, 'error' => 'bad token']);
+ }
+
+ $depth = (int)$syntax->getConf('pagelink_search_depth');
+ if ($depth < 0) $depth = 0;
+
+ $pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
+ $uuid = $pageLink->getPageUuid($pageId);
+
+ if ($uuid !== null) {
+ luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => false]);
+ }
+
+ $uuid = PageLink::createUuidV4();
+ $ok = $pageLink->setPageUuid($pageId, $uuid);
+
+ if (!$ok) {
+ luxtools_pagelink_json(500, ['ok' => false, 'error' => 'save failed']);
+ }
+
+ luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => true]);
+}
+
+luxtools_pagelink_json(400, ['ok' => false, 'error' => 'unknown command']);
diff --git a/src/PageLink.php b/src/PageLink.php
new file mode 100644
index 0000000..0e6aec2
--- /dev/null
+++ b/src/PageLink.php
@@ -0,0 +1,296 @@
+pathConfig = $pathConfig;
+ $this->maxDepth = max(0, $maxDepth);
+ }
+
+ /**
+ * Generate a v4 UUID (lowercase).
+ */
+ public static function createUuidV4(): string
+ {
+ $bytes = random_bytes(16);
+ $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
+ $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
+ $hex = bin2hex($bytes);
+ return sprintf(
+ '%s-%s-%s-%s-%s',
+ substr($hex, 0, 8),
+ substr($hex, 8, 4),
+ substr($hex, 12, 4),
+ substr($hex, 16, 4),
+ substr($hex, 20, 12)
+ );
+ }
+
+ /**
+ * Normalize and validate a UUID v4 string.
+ *
+ * @param string $uuid
+ * @return string|null
+ */
+ public static function normalizeUuid(string $uuid): ?string
+ {
+ $uuid = strtolower(trim($uuid));
+ if ($uuid === '') return null;
+
+ if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) {
+ return null;
+ }
+ return $uuid;
+ }
+
+ /**
+ * Get the page's pagelink UUID from metadata (if valid).
+ */
+ public function getPageUuid(string $pageId): ?string
+ {
+ if ($pageId === '') return null;
+ if (!function_exists('p_get_metadata')) return null;
+
+ $value = p_get_metadata($pageId, self::META_KEY, METADATA_DONT_RENDER);
+ if (!is_string($value)) return null;
+ return self::normalizeUuid($value);
+ }
+
+ /**
+ * Persist a pagelink UUID in page metadata.
+ */
+ public function setPageUuid(string $pageId, string $uuid): bool
+ {
+ if ($pageId === '') return false;
+ if (!function_exists('p_set_metadata')) return false;
+ $uuid = self::normalizeUuid($uuid);
+ if ($uuid === null) return false;
+
+ return (bool)p_set_metadata($pageId, [self::META_KEY => $uuid]);
+ }
+
+ /**
+ * Resolve a pagelink UUID to a linked folder (if any).
+ *
+ * @param string $uuid
+ * @return array{folder: string|null, multiple: bool}
+ */
+ public function resolveUuid(string $uuid): array
+ {
+ $uuid = self::normalizeUuid($uuid);
+ if ($uuid === null) {
+ return ['folder' => null, 'multiple' => false];
+ }
+
+ $cache = $this->loadCache();
+ if (isset($cache[$uuid]) && is_string($cache[$uuid]) && $cache[$uuid] !== '') {
+ $cachedPath = $cache[$uuid];
+ if ($this->isValidLink($cachedPath, $uuid)) {
+ return ['folder' => $cachedPath, 'multiple' => false];
+ }
+
+ unset($cache[$uuid]);
+ $this->cacheDirty = true;
+ }
+
+ $matches = $this->scanRootsForUuid($uuid, 2);
+ if ($matches !== []) {
+ $cache[$uuid] = $matches[0];
+ $this->cacheDirty = true;
+ }
+
+ if ($this->cacheDirty) {
+ $this->writeCache($cache);
+ }
+
+ return [
+ 'folder' => $matches[0] ?? null,
+ 'multiple' => count($matches) > 1,
+ ];
+ }
+
+ /**
+ * Read the cache file into memory.
+ *
+ * @return array
+ */
+ protected function loadCache(): array
+ {
+ if ($this->cache !== null) return $this->cache;
+
+ $this->cache = [];
+ $file = $this->getCacheFile();
+ if (!is_file($file) || !is_readable($file)) return $this->cache;
+
+ $raw = @file_get_contents($file);
+ if (!is_string($raw) || $raw === '') return $this->cache;
+
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded)) return $this->cache;
+
+ $this->cache = $decoded;
+ return $this->cache;
+ }
+
+ /**
+ * Write cache to disk atomically.
+ */
+ protected function writeCache(array $cache): void
+ {
+ $file = $this->getCacheFile();
+ $dir = dirname($file);
+ if (function_exists('io_mkdir_p')) {
+ io_mkdir_p($dir);
+ } elseif (!@is_dir($dir)) {
+ @mkdir($dir, 0777, true);
+ }
+
+ $tmp = $file . '.tmp.' . getmypid();
+ $data = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($data === false) return;
+
+ @file_put_contents($tmp, $data, LOCK_EX);
+ if (!@rename($tmp, $file)) {
+ @copy($tmp, $file);
+ @unlink($tmp);
+ }
+
+ $this->cache = $cache;
+ $this->cacheDirty = false;
+ }
+
+ /**
+ * Get cache file path for pagelink mappings.
+ */
+ protected function getCacheFile(): string
+ {
+ global $conf;
+ $cacheDir = rtrim((string)$conf['cachedir'], '/');
+ return $cacheDir . '/luxtools/' . self::CACHE_FILE;
+ }
+
+ /**
+ * Check if the cached path still points to a valid .pagelink file.
+ */
+ protected function isValidLink(string $folder, string $uuid): bool
+ {
+ if ($folder === '') return false;
+ if (!is_dir($folder)) return false;
+ if (is_link($folder)) return false;
+
+ $file = rtrim($folder, '/\\') . '/.pagelink';
+ if (!is_file($file) || is_link($file) || !is_readable($file)) return false;
+
+ $content = @file_get_contents($file);
+ if (!is_string($content)) return false;
+
+ $content = self::normalizeUuid($content);
+ return $content !== null && $content === $uuid;
+ }
+
+ /**
+ * Scan configured roots for matching .pagelink files.
+ *
+ * @param string $uuid
+ * @param int $limit Maximum number of matches to collect.
+ * @return string[]
+ */
+ protected function scanRootsForUuid(string $uuid, int $limit = 2): array
+ {
+ $roots = $this->getConfiguredRoots();
+ if ($roots === []) return [];
+
+ $matches = [];
+ foreach ($roots as $root) {
+ $this->scanDirectory($root, 0, $uuid, $limit, $matches);
+ if (count($matches) >= $limit) break;
+ }
+ return $matches;
+ }
+
+ /**
+ * Recursively scan a directory for .pagelink files.
+ *
+ * @param string $dir
+ * @param int $depth
+ * @param string $uuid
+ * @param int $limit
+ * @param array $matches
+ */
+ protected function scanDirectory(string $dir, int $depth, string $uuid, int $limit, array &$matches): void
+ {
+ if ($dir === '' || count($matches) >= $limit) return;
+ if (!is_dir($dir) || is_link($dir)) return;
+ if (!is_readable($dir)) return;
+ if ($depth > $this->maxDepth) return;
+
+ $file = rtrim($dir, '/\\') . '/.pagelink';
+ if (is_file($file) && !is_link($file) && is_readable($file)) {
+ $content = @file_get_contents($file);
+ if (is_string($content)) {
+ $content = self::normalizeUuid($content);
+ if ($content !== null && $content === $uuid) {
+ $matches[] = rtrim($dir, '/\\');
+ if (count($matches) >= $limit) return;
+ }
+ }
+ }
+
+ if ($depth >= $this->maxDepth) return;
+
+ $handle = @opendir($dir);
+ if ($handle === false) return;
+
+ $base = rtrim($dir, '/\\');
+ while (($entry = readdir($handle)) !== false) {
+ if ($entry === '.' || $entry === '..') continue;
+ if ($entry === '.pagelink') continue;
+
+ $path = $base . '/' . $entry;
+ if (!is_dir($path) || is_link($path)) continue;
+
+ $this->scanDirectory($path, $depth + 1, $uuid, $limit, $matches);
+ if (count($matches) >= $limit) break;
+ }
+
+ closedir($handle);
+ }
+
+ /**
+ * Resolve configured root paths (excluding aliases).
+ *
+ * @return string[]
+ */
+ protected function getConfiguredRoots(): array
+ {
+ $pathConfig = trim($this->pathConfig);
+ if ($pathConfig === '') return [];
+
+ $helper = new Path($pathConfig);
+ $paths = $helper->getPaths();
+ $roots = [];
+ foreach ($paths as $key => $info) {
+ if (!isset($info['root']) || $key !== $info['root']) continue;
+ $roots[] = $info['root'];
+ }
+ return $roots;
+ }
+}
diff --git a/style.css b/style.css
index 7b064bc..d8c09d7 100644
--- a/style.css
+++ b/style.css
@@ -49,6 +49,36 @@ div.luxtools-plugin .luxtools-empty {
padding: 0.25em 0;
}
+/* Page link status (next to page title) */
+span.luxtools-pagelink-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ font-size: 0.75em;
+ line-height: 1.2;
+ margin: 0.25em 0;
+ padding: 0.15em 0.4em;
+ border: 1px solid @ini_border;
+ border-radius: 0.2em;
+ background-color: @ini_background_alt;
+ color: inherit;
+}
+
+span.luxtools-pagelink-status.is-inline {
+ margin-left: 0.5em;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+span.luxtools-pagelink-status[data-copy="1"] {
+ cursor: pointer;
+}
+
+span.luxtools-pagelink-warning {
+ font-size: 0.95em;
+ opacity: 0.8;
+}
+
/* Image gallery spacing. */
div.luxtools-gallery {
padding-bottom: 0.5em;
diff --git a/syntax/AbstractSyntax.php b/syntax/AbstractSyntax.php
index 3aea4c5..66fe8ee 100644
--- a/syntax/AbstractSyntax.php
+++ b/syntax/AbstractSyntax.php
@@ -4,6 +4,7 @@ use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Crawler;
use dokuwiki\plugin\luxtools\Output;
use dokuwiki\plugin\luxtools\Path;
+use dokuwiki\plugin\luxtools\PageLink;
require_once(__DIR__ . '/../autoload.php');
@@ -208,7 +209,12 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
{
try {
- $pathHelper = new Path($this->getConf('paths'));
+ $pathConfig = (string)$this->getConf('paths');
+ $blobsRoot = $this->resolveBlobsRoot();
+ if ($blobsRoot !== '') {
+ $pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
+ }
+ $pathHelper = new Path($pathConfig);
return $pathHelper->getPathInfo($basePath);
} catch (\Exception $e) {
$this->renderError($renderer, 'error_outsidejail');
@@ -216,6 +222,50 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
}
}
+ /**
+ * Resolve the current page's pagelink folder for the blobs alias.
+ *
+ * @return string
+ */
+ protected function resolveBlobsRoot(): string
+ {
+ static $cached = [];
+
+ global $ID;
+ $pageId = is_string($ID) ? $ID : '';
+ if ($pageId === '') return '';
+
+ if (function_exists('cleanID')) {
+ $pageId = (string)cleanID($pageId);
+ }
+ if ($pageId === '') return '';
+
+ if (isset($cached[$pageId])) {
+ return (string)$cached[$pageId];
+ }
+
+ $pathConfig = (string)$this->getConf('paths');
+ $depth = (int)$this->getConf('pagelink_search_depth');
+ if ($depth < 0) $depth = 0;
+
+ $pageLink = new PageLink($pathConfig, $depth);
+ $uuid = $pageLink->getPageUuid($pageId);
+ if ($uuid === null) {
+ $cached[$pageId] = '';
+ return '';
+ }
+
+ $linkInfo = $pageLink->resolveUuid($uuid);
+ $folder = $linkInfo['folder'] ?? '';
+ if (!is_string($folder) || $folder === '') {
+ $cached[$pageId] = '';
+ return '';
+ }
+
+ $cached[$pageId] = $folder;
+ return $folder;
+ }
+
/**
* Create and configure a Crawler instance.
*
diff --git a/syntax/image.php b/syntax/image.php
index 849cdb9..1992c82 100644
--- a/syntax/image.php
+++ b/syntax/image.php
@@ -2,6 +2,7 @@
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Path;
+use dokuwiki\plugin\luxtools\PageLink;
use dokuwiki\plugin\luxtools\ThumbnailHelper;
require_once(__DIR__ . '/../autoload.php');
@@ -132,7 +133,7 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
}
try {
- $pathHelper = new Path($this->getConf('paths'));
+ $pathHelper = new Path($this->buildPathConfigWithBlobs());
// Use addTrailingSlash=false since this is a file path, not a directory
$pathInfo = $pathHelper->getPathInfo($data['path'], false);
} catch (\Exception $e) {
@@ -214,6 +215,60 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
}
+ /**
+ * Build a path configuration string, adding the blobs alias if available.
+ */
+ protected function buildPathConfigWithBlobs(): string
+ {
+ $pathConfig = (string)$this->getConf('paths');
+ $blobsRoot = $this->resolveBlobsRoot();
+ if ($blobsRoot !== '') {
+ $pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
+ }
+ return $pathConfig;
+ }
+
+ /**
+ * Resolve the current page's pagelink folder for the blobs alias.
+ */
+ protected function resolveBlobsRoot(): string
+ {
+ static $cached = [];
+
+ global $ID;
+ $pageId = is_string($ID) ? $ID : '';
+ if ($pageId === '') return '';
+
+ if (function_exists('cleanID')) {
+ $pageId = (string)cleanID($pageId);
+ }
+ if ($pageId === '') return '';
+
+ if (isset($cached[$pageId])) {
+ return (string)$cached[$pageId];
+ }
+
+ $depth = (int)$this->getConf('pagelink_search_depth');
+ if ($depth < 0) $depth = 0;
+
+ $pageLink = new PageLink((string)$this->getConf('paths'), $depth);
+ $uuid = $pageLink->getPageUuid($pageId);
+ if ($uuid === null) {
+ $cached[$pageId] = '';
+ return '';
+ }
+
+ $linkInfo = $pageLink->resolveUuid($uuid);
+ $folder = $linkInfo['folder'] ?? '';
+ if (!is_string($folder) || $folder === '') {
+ $cached[$pageId] = '';
+ return '';
+ }
+
+ $cached[$pageId] = $folder;
+ return $folder;
+ }
+
/**
* Render the imagebox HTML.
*