From 4dae370deb21dbea270361c71aa1b73e5c82504b Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 9 Feb 2026 09:23:04 +0100 Subject: [PATCH] Refine Page Linking workflow --- .github/spec.prompt.md | 88 ---------------------- README.md | 25 +++--- action.php | 8 -- js/page-link.js | 167 +++++++++++++++++------------------------ lang/de/lang.php | 3 +- lang/en/lang.php | 3 +- pagelink.php | 2 +- src/PageLinkTrait.php | 5 +- style.css | 10 +-- 9 files changed, 91 insertions(+), 220 deletions(-) delete mode 100644 .github/spec.prompt.md diff --git a/.github/spec.prompt.md b/.github/spec.prompt.md deleted file mode 100644 index 1446625..0000000 --- a/.github/spec.prompt.md +++ /dev/null @@ -1,88 +0,0 @@ -# Page Link Feature Specification - -## Goals -- Allow linking a Dokuwiki page to a media folder via a stable UUID. -- Enable the `blobs` alias so plugin syntaxes can use `blobs/*` in place of a concrete path when the page is linked. -- Keep behavior stable and maintainable; avoid heavy scanning on each page view. - -## Non-Goals -- Automatic creation of `.pagelink` files (user must create manually). -- Automatic selection of which folder to link (user decides by placing `.pagelink`). -- Cross-page bulk management UI. - -## Core Concept -- Each page can have **one** UUID stored in page metadata. -- A folder is considered linked to the page if it contains a `.pagelink` file whose content matches the page’s UUID. - -## Data Model -- Page metadata key: `pagelink` with value ``. -- `.pagelink` file content: a UUID string. -- Cache mapping file in plugin cache folder: maps UUID → folder path. - - Cache file format: JSON (human-readable, easy to debug). - - Cache file name: `pagelink_cache.json`. - -## UUID -- UUID is created only via a toolbar button. -- UUID is created once and stored in metadata. - - UUID is v4 lowercase. -- UUID is not shown directly to the user (except for copy-to-clipboard). - -## UI/UX Behavior -- Above page content (next to pageid), show link status. -- If UUID exists and folder is linked: show the linked folder name. -- If UUID exists but no folder is linked: show a “not linked yet” status. - - Clicking the status copies the UUID to clipboard for easy `.pagelink` creation. - - Status text: "Not linked: Copy ID" - - Copy action does not show anything. No native toasts available -- If UUID does not exist: no status is shown. - -## Linking Rules -- Folder can be moved or renamed; link remains as long as `.pagelink` file stays in that folder. -- Deleting the `.pagelink` file unlinks the folder. -- If multiple folders contain the same UUID: - - Page shows a linked status with the first found folder. - - Triangle warning icon appears next to folder name. - -## Search Scope -- Searches for `.pagelink` files are limited to paths under root aliases defined in `paths` in [admin/main.php](../admin/main.php). -- New setting `pagelink_search_depth` limits maximum depth under each root path. - - Example: depth `2` means only folders at most 2 levels deep are searched. - - Integer value, default `3`. - - No negative values allowed. - -## Cache Strategy -- Mapping of UUID → folder path is cached in a mapping file for performance. -- On page load: - 1. If page has UUID, check cache for linked folder. - 2. If cache miss, scan folders (within scope) for `.pagelink`. - 3. Update cache with any matches. -- Cache invalidation strategy: - - No automatic cache rebuilds. - - When encountering stale cache entry (moved/deleted folder) while looking for a pages linked folder, remove from cache and rescan. - -- Cache writes are atomic (write temp + rename) and use `LOCK_EX`. - -## Performance Constraints -- Avoid full-tree scans on every page view. - -## Security & Safety -- Never scan outside configured root paths. -- ignore symlinks. -- Handle unreadable folders/files gracefully (no fatal errors). - -## Acceptance Criteria -- Clicking toolbar button sets metadata `pagelink` to a valid UUID. -- If a matching `.pagelink` exists in a scoped folder, `blobs/*` resolves to that folder. -- If `.pagelink` is deleted, the link is removed on next lookup and UI reflects “not linked yet”. -- Cache prevents repeated scans on consecutive page views. -- Search depth obeys `pagelink_search_depth`. - -## Edge Cases -- Page has UUID but `.pagelink` file is empty or invalid UUID. -- Multiple `.pagelink` files with same UUID in different folders. -- UUID in metadata is malformed. -- Folder contains `.pagelink` but no read permissions. -- Page is deleted/renamed after UUID creation. - -## Documentation Updates -- Update README to describe the feature, settings, and manual `.pagelink` workflow. \ No newline at end of file diff --git a/README.md b/README.md index 6be0d4c..f1ee3c4 100644 --- a/README.md +++ b/README.md @@ -212,18 +212,25 @@ Supported input examples include: - `2026-01-30 13:45` - `2026-01-30T13:45:00` -### 0.2) Editor toolbar: Page Link +### 0.2) Page Link: link a page to a folder -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. +Page linking uses a page-scoped UUID stored in page metadata. This UUID is used +to link the page to a folder that contains a `.pagelink` file with the same UUID. -Workflow: +The Page Link workflow is driven by the **Page ID link** in the page info area +(page footer, `.docInfo`): -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. +1. **Link Page** (page has no UUID yet) + Creates the UUID and downloads a `.pagelink` file. +2. **Download Link File** (page has UUID, but no linked folder found) + Downloads the `.pagelink` file. +3. **Unlink Page** (page is linked) + Prompts for confirmation, removes the `.pagelink` file from the linked folder + (if found), removes the UUID from the page, and refreshes the page. + +After downloading the `.pagelink` file, place it into the folder you want to +link (within your configured `paths` roots). Once DokuWiki can discover it, +the page becomes “linked”. Once linked, you can use `blobs/` as an alias in luxtools syntax on that page, for example: diff --git a/action.php b/action.php index 8e5e7a2..503eddd 100644 --- a/action.php +++ b/action.php @@ -120,13 +120,5 @@ 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, - ]; } } diff --git a/js/page-link.js b/js/page-link.js index be75498..c6402f0 100644 --- a/js/page-link.js +++ b/js/page-link.js @@ -43,7 +43,7 @@ function requestPageLink(cmd, params) { var pageId = getPageId(); - if (!pageId) return false; + if (!pageId) return Promise.reject(new Error('missing page id')); var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; @@ -56,7 +56,7 @@ }); } - window.fetch(endpoint, { + return window.fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, @@ -68,13 +68,7 @@ } return body; }); - }).catch(function (err) { - if (window.console && window.console.warn) { - window.console.warn('PageLink request failed:', err); - } }); - - return false; } function ensurePageLink() { @@ -85,44 +79,38 @@ return requestPageLink('unlink', { sectok: getSectok() }); } - function copyToClipboard(text) { - if (!text) return; - if (window.navigator && window.navigator.clipboard && window.navigator.clipboard.writeText) { - window.navigator.clipboard.writeText(text).catch(function () {}); - return; - } - + function triggerDownload(pageId) { 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) {} + var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; + var href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId); + + var a = document.createElement('a'); + a.href = href; + a.download = '.pagelink'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (e) { + // ignore + } } - function attachCopyTargets() { - var targets = document.querySelectorAll('[data-luxtools-pagelink-copy="1"]'); - if (!targets || !targets.length) return; + function fetchPageLinkInfo(pageId) { + if (!pageId) return Promise.reject(new Error('missing page id')); - targets.forEach(function (el) { - if (!el || !el.getAttribute) return; - el.setAttribute('role', 'button'); - el.setAttribute('tabindex', '0'); - el.addEventListener('click', function (e) { - e.preventDefault(); - copyToClipboard(String(el.getAttribute('data-uuid') || '').trim()); - }); - el.addEventListener('keydown', function (e) { - if (!e || (e.key !== 'Enter' && e.key !== ' ')) return; - e.preventDefault(); - copyToClipboard(String(el.getAttribute('data-uuid') || '').trim()); + var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; + var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId); + + return window.fetch(query, { + method: 'GET', + credentials: 'same-origin' + }).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; }); }); } @@ -136,25 +124,45 @@ var pageId = getPageId(); if (!pageId) return; - var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; - var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId); - - window.fetch(query, { - method: 'GET', - credentials: 'same-origin' - }).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; - }); - }).then(function (info) { - if (!info || !info.uuid) return; - + fetchPageLinkInfo(pageId).then(function (info) { var link = document.createElement('a'); - link.href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId); - link.textContent = 'Page ID: ' + String(info.uuid); + link.href = '#'; + + if (!info || !info.uuid) { + link.textContent = 'Link Page'; + link.addEventListener('click', function (e) { + e.preventDefault(); + ensurePageLink().then(function (res) { + if (!res || !res.uuid) throw new Error('no uuid'); + triggerDownload(pageId); + }).catch(function (err) { + if (window.console && window.console.warn) { + window.console.warn('PageLink ensure failed:', err); + } + }); + }); + } else if (info.linked) { + link.textContent = 'Unlink Page'; + link.addEventListener('click', function (e) { + e.preventDefault(); + if (!window.confirm('Unlink page?')) return; + unlinkPageLink().then(function () { + window.setTimeout(function () { + try { window.location.reload(); } catch (e2) {} + }, 400); + }).catch(function (err) { + if (window.console && window.console.warn) { + window.console.warn('PageLink unlink failed:', err); + } + }); + }); + } else { + link.textContent = 'Download Link File'; + link.addEventListener('click', function (e) { + e.preventDefault(); + triggerDownload(pageId); + }); + } var first = container.firstChild; container.insertBefore(link, first); @@ -166,46 +174,7 @@ }); } - window.addBtnActionLuxtoolsPageLink = function ($btn, props, edid) { - $btn.on('click', function () { - var pageId = getPageId(); - if (!pageId) return false; - - var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; - var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId); - - window.fetch(query, { - method: 'GET', - credentials: 'same-origin' - }).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; - }); - }).then(function (info) { - if (info && info.uuid) { - if (window.confirm('Unlink page?')) { - unlinkPageLink(); - } - return; - } - - ensurePageLink(); - }).catch(function (err) { - if (window.console && window.console.warn) { - window.console.warn('PageLink info failed:', err); - } - ensurePageLink(); - }); - return false; - }); - return 'luxtools-pagelink'; - }; - document.addEventListener('DOMContentLoaded', function () { - attachCopyTargets(); attachDocInfoLink(); }, false); })(); diff --git a/lang/de/lang.php b/lang/de/lang.php index 5e4f046..9ed85df 100644 --- a/lang/de/lang.php +++ b/lang/de/lang.php @@ -77,6 +77,5 @@ $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"] = "Seite nicht verknüpft (ID kopieren)"; +$lang["pagelink_unlinked"] = "Seite nicht verknüpft"; $lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft"; diff --git a/lang/en/lang.php b/lang/en/lang.php index bd4f74d..63c7fe5 100644 --- a/lang/en/lang.php +++ b/lang/en/lang.php @@ -77,6 +77,5 @@ $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"] = "Page not linked (Copy ID)"; +$lang["pagelink_unlinked"] = "Page not linked"; $lang["pagelink_multi_warning"] = "Multiple folders linked"; diff --git a/pagelink.php b/pagelink.php index 52b0c97..ee3dd98 100644 --- a/pagelink.php +++ b/pagelink.php @@ -107,7 +107,7 @@ if ($cmd === 'download') { http_status(200); header('Content-Type: text/plain; charset=utf-8'); - header('Content-Disposition: attachment; filename=".pagelink"'); + header('Content-Disposition: attachment; filename=".pagelink"; filename*=UTF-8\'\'%2Epagelink'); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); echo $uuid; diff --git a/src/PageLinkTrait.php b/src/PageLinkTrait.php index c2d2a62..d7849d4 100644 --- a/src/PageLinkTrait.php +++ b/src/PageLinkTrait.php @@ -36,13 +36,10 @@ trait PageLinkTrait */ protected function renderPageNotLinked(\Doku_Renderer $renderer): void { - $uuid = $this->getPageUuidSafe(); $text = (string)$this->getLang('pagelink_unlinked'); if ($renderer instanceof \Doku_Renderer_xhtml) { - $renderer->doc .= '' . hsc($text) . ''; + $renderer->doc .= '' . hsc($text) . ''; return; } diff --git a/style.css b/style.css index 1ff1a21..dc84705 100644 --- a/style.css +++ b/style.css @@ -49,11 +49,9 @@ div.luxtools-plugin .luxtools-empty { padding: 0.25em 0; } -/* Page link copy message (unlinked blobs alias) */ -a.luxtools-pagelink-copy, -a.luxtools-pagelink-copy:visited { - display: inline-flex; - align-items: center; +/* Page link status (unlinked blobs alias) */ +span.luxtools-pagelink-status { + display: inline-block; font-size: 0.85em; line-height: 1.3; margin: 0.25em 0; @@ -62,8 +60,6 @@ a.luxtools-pagelink-copy:visited { border-radius: 0.2em; background-color: @ini_background_alt; color: inherit; - text-decoration: none; - cursor: pointer; } /* Image gallery spacing. */