Refine Page Linking workflow
This commit is contained in:
88
.github/spec.prompt.md
vendored
88
.github/spec.prompt.md
vendored
@@ -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 `<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.
|
|
||||||
25
README.md
25
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
js/page-link.js
167
js/page-link.js
@@ -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;
|
|
||||||
if (window.navigator && window.navigator.clipboard && window.navigator.clipboard.writeText) {
|
|
||||||
window.navigator.clipboard.writeText(text).catch(function () {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var textarea = document.createElement('textarea');
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
textarea.value = text;
|
var href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId);
|
||||||
textarea.setAttribute('readonly', 'readonly');
|
|
||||||
textarea.style.position = 'absolute';
|
var a = document.createElement('a');
|
||||||
textarea.style.left = '-9999px';
|
a.href = href;
|
||||||
document.body.appendChild(textarea);
|
a.download = '.pagelink';
|
||||||
textarea.select();
|
a.style.display = 'none';
|
||||||
try {
|
document.body.appendChild(a);
|
||||||
document.execCommand('copy');
|
a.click();
|
||||||
} catch (e) {}
|
document.body.removeChild(a);
|
||||||
document.body.removeChild(textarea);
|
} catch (e) {
|
||||||
} catch (e2) {}
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachCopyTargets() {
|
function fetchPageLinkInfo(pageId) {
|
||||||
var targets = document.querySelectorAll('[data-luxtools-pagelink-copy="1"]');
|
if (!pageId) return Promise.reject(new Error('missing page id'));
|
||||||
if (!targets || !targets.length) return;
|
|
||||||
|
|
||||||
targets.forEach(function (el) {
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
if (!el || !el.getAttribute) return;
|
var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId);
|
||||||
el.setAttribute('role', 'button');
|
|
||||||
el.setAttribute('tabindex', '0');
|
return window.fetch(query, {
|
||||||
el.addEventListener('click', function (e) {
|
method: 'GET',
|
||||||
e.preventDefault();
|
credentials: 'same-origin'
|
||||||
copyToClipboard(String(el.getAttribute('data-uuid') || '').trim());
|
}).then(function (res) {
|
||||||
});
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
el.addEventListener('keydown', function (e) {
|
if (!res.ok || !body || body.ok !== true) {
|
||||||
if (!e || (e.key !== 'Enter' && e.key !== ' ')) return;
|
throw new Error('request failed');
|
||||||
e.preventDefault();
|
}
|
||||||
copyToClipboard(String(el.getAttribute('data-uuid') || '').trim());
|
return body;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
style.css
10
style.css
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user