Refine Page Linking workflow

This commit is contained in:
2026-02-09 09:23:04 +01:00
parent a5c44e106e
commit 4dae370deb
9 changed files with 91 additions and 220 deletions

View File

@@ -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 pages UUID.
## Data Model
- Page metadata key: `pagelink` with value `<UUID>`.
- `.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.

View File

@@ -212,18 +212,25 @@ Supported input examples include:
- `2026-01-30 13:45` - `2026-01-30 13:45`
- `2026-01-30T13:45:00` - `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 linking uses a page-scoped UUID stored in page metadata. This UUID is used
page metadata. This UUID is used to link the page to a folder that contains to link the page to a folder that contains a `.pagelink` file with the same UUID.
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. 1. **Link Page** (page has no UUID yet)
2. View the page and copy the UUID from the “Not linked: Copy ID” status. Creates the UUID and downloads a `.pagelink` file.
3. Create a `.pagelink` file in the target folder (within your configured 2. **Download Link File** (page has UUID, but no linked folder found)
`paths` roots) and paste the UUID into that file. 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, Once linked, you can use `blobs/` as an alias in luxtools syntax on that page,
for example: for example:

View File

@@ -120,13 +120,5 @@ class action_plugin_luxtools extends ActionPlugin
"icon" => "../../plugins/luxtools/images/date-fix-all.svg", "icon" => "../../plugins/luxtools/images/date-fix-all.svg",
"block" => false, "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,
];
} }
} }

View File

@@ -43,7 +43,7 @@
function requestPageLink(cmd, params) { function requestPageLink(cmd, params) {
var pageId = getPageId(); 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'; var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
@@ -56,7 +56,7 @@
}); });
} }
window.fetch(endpoint, { return window.fetch(endpoint, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
@@ -68,13 +68,7 @@
} }
return body; return body;
}); });
}).catch(function (err) {
if (window.console && window.console.warn) {
window.console.warn('PageLink request failed:', err);
}
}); });
return false;
} }
function ensurePageLink() { function ensurePageLink() {
@@ -85,44 +79,38 @@
return requestPageLink('unlink', { sectok: getSectok() }); return requestPageLink('unlink', { sectok: getSectok() });
} }
function copyToClipboard(text) { function triggerDownload(pageId) {
if (!text) return; try {
if (window.navigator && window.navigator.clipboard && window.navigator.clipboard.writeText) { var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
window.navigator.clipboard.writeText(text).catch(function () {}); var href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId);
return;
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
}
} }
try { function fetchPageLinkInfo(pageId) {
var textarea = document.createElement('textarea'); if (!pageId) return Promise.reject(new Error('missing page id'));
textarea.value = text;
textarea.setAttribute('readonly', 'readonly'); var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
textarea.style.position = 'absolute'; var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId);
textarea.style.left = '-9999px';
document.body.appendChild(textarea); return window.fetch(query, {
textarea.select(); method: 'GET',
try { credentials: 'same-origin'
document.execCommand('copy'); }).then(function (res) {
} catch (e) {} return res.json().catch(function () { return null; }).then(function (body) {
document.body.removeChild(textarea); if (!res.ok || !body || body.ok !== true) {
} catch (e2) {} throw new Error('request failed');
} }
return body;
function attachCopyTargets() {
var targets = document.querySelectorAll('[data-luxtools-pagelink-copy="1"]');
if (!targets || !targets.length) return;
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());
}); });
}); });
} }
@@ -136,25 +124,45 @@
var pageId = getPageId(); var pageId = getPageId();
if (!pageId) return; if (!pageId) return;
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php'; fetchPageLinkInfo(pageId).then(function (info) {
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;
var link = document.createElement('a'); var link = document.createElement('a');
link.href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId); link.href = '#';
link.textContent = 'Page ID: ' + String(info.uuid);
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; var first = container.firstChild;
container.insertBefore(link, first); 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 () { document.addEventListener('DOMContentLoaded', function () {
attachCopyTargets();
attachDocInfoLink(); attachDocInfoLink();
}, false); }, false);
})(); })();

View File

@@ -77,6 +77,5 @@ $lang["toolbar_code_title"] = "Code-Block";
$lang["toolbar_code_sample"] = "Ihr Code hier"; $lang["toolbar_code_sample"] = "Ihr Code hier";
$lang["toolbar_datefix_title"] = "Datums-Fix"; $lang["toolbar_datefix_title"] = "Datums-Fix";
$lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)"; $lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)";
$lang["toolbar_pagelink_title"] = "Seiten-Link"; $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
$lang["pagelink_unlinked"] = "Seite nicht verknüpft (ID kopieren)";
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft"; $lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";

View File

@@ -77,6 +77,5 @@ $lang["toolbar_code_title"] = "Code Block";
$lang["toolbar_code_sample"] = "your code here"; $lang["toolbar_code_sample"] = "your code here";
$lang["toolbar_datefix_title"] = "Date Fix"; $lang["toolbar_datefix_title"] = "Date Fix";
$lang["toolbar_datefix_all_title"] = "Date Fix (All)"; $lang["toolbar_datefix_all_title"] = "Date Fix (All)";
$lang["toolbar_pagelink_title"] = "Page Link"; $lang["pagelink_unlinked"] = "Page not linked";
$lang["pagelink_unlinked"] = "Page not linked (Copy ID)";
$lang["pagelink_multi_warning"] = "Multiple folders linked"; $lang["pagelink_multi_warning"] = "Multiple folders linked";

View File

@@ -107,7 +107,7 @@ if ($cmd === 'download') {
http_status(200); http_status(200);
header('Content-Type: text/plain; charset=utf-8'); 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('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache'); header('Pragma: no-cache');
echo $uuid; echo $uuid;

View File

@@ -36,13 +36,10 @@ trait PageLinkTrait
*/ */
protected function renderPageNotLinked(\Doku_Renderer $renderer): void protected function renderPageNotLinked(\Doku_Renderer $renderer): void
{ {
$uuid = $this->getPageUuidSafe();
$text = (string)$this->getLang('pagelink_unlinked'); $text = (string)$this->getLang('pagelink_unlinked');
if ($renderer instanceof \Doku_Renderer_xhtml) { if ($renderer instanceof \Doku_Renderer_xhtml) {
$renderer->doc .= '<a href="#" class="luxtools-pagelink-copy" data-luxtools-pagelink-copy="1"' $renderer->doc .= '<span class="luxtools-pagelink-status">' . hsc($text) . '</span>';
. ' data-uuid="' . hsc($uuid) . '"'
. '>' . hsc($text) . '</a>';
return; return;
} }

View File

@@ -49,11 +49,9 @@ div.luxtools-plugin .luxtools-empty {
padding: 0.25em 0; padding: 0.25em 0;
} }
/* Page link copy message (unlinked blobs alias) */ /* Page link status (unlinked blobs alias) */
a.luxtools-pagelink-copy, span.luxtools-pagelink-status {
a.luxtools-pagelink-copy:visited { display: inline-block;
display: inline-flex;
align-items: center;
font-size: 0.85em; font-size: 0.85em;
line-height: 1.3; line-height: 1.3;
margin: 0.25em 0; margin: 0.25em 0;
@@ -62,8 +60,6 @@ a.luxtools-pagelink-copy:visited {
border-radius: 0.2em; border-radius: 0.2em;
background-color: @ini_background_alt; background-color: @ini_background_alt;
color: inherit; color: inherit;
text-decoration: none;
cursor: pointer;
} }
/* Image gallery spacing. */ /* Image gallery spacing. */