Compare commits
5 Commits
487e96b588
...
1b6df4a9e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b6df4a9e4 | |||
| 47a8bfa50a | |||
| e1102d9f06 | |||
| af0ca29131 | |||
| c203fe6397 |
88
.github/spec.prompt.md
vendored
Normal file
88
.github/spec.prompt.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# 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.
|
||||||
26
README.md
26
README.md
@@ -25,6 +25,7 @@ luxtools provides DokuWiki syntax that:
|
|||||||
- Renders an image thumbnail gallery (with lightbox)
|
- Renders an image thumbnail gallery (with lightbox)
|
||||||
- Provides "open this folder/path" links for local workflows
|
- Provides "open this folder/path" links for local workflows
|
||||||
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
|
- 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
|
It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used
|
||||||
to deliver files and generate cached thumbnails.
|
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.
|
URL of a local client service used by `{{open>...}}` and directory links.
|
||||||
See luxtools-client.
|
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
|
### Template style settings
|
||||||
|
|
||||||
The `{{open>...}}` links and directory “open” links use a dedicated color
|
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-30 13:45`
|
||||||
- `2026-01-30T13:45:00`
|
- `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
|
### 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:
|
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:
|
||||||
|
|||||||
10
action.php
10
action.php
@@ -3,6 +3,7 @@
|
|||||||
use dokuwiki\Extension\ActionPlugin;
|
use dokuwiki\Extension\ActionPlugin;
|
||||||
use dokuwiki\Extension\Event;
|
use dokuwiki\Extension\Event;
|
||||||
use dokuwiki\Extension\EventHandler;
|
use dokuwiki\Extension\EventHandler;
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* luxtools action plugin: register JS assets.
|
* luxtools action plugin: register JS assets.
|
||||||
@@ -49,6 +50,7 @@ class action_plugin_luxtools extends ActionPlugin
|
|||||||
"open-service.js",
|
"open-service.js",
|
||||||
"scratchpads.js",
|
"scratchpads.js",
|
||||||
"date-fix.js",
|
"date-fix.js",
|
||||||
|
"page-link.js",
|
||||||
"linkfavicon.js",
|
"linkfavicon.js",
|
||||||
"main.js",
|
"main.js",
|
||||||
];
|
];
|
||||||
@@ -118,5 +120,13 @@ 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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
'thumb_placeholder',
|
'thumb_placeholder',
|
||||||
'gallery_thumb_scale',
|
'gallery_thumb_scale',
|
||||||
'open_service_url',
|
'open_service_url',
|
||||||
|
'pagelink_search_depth',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getMenuText($language)
|
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['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale');
|
||||||
$newConf['open_service_url'] = $INPUT->str('open_service_url');
|
$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)) {
|
if ($this->savePluginLocalConf($newConf)) {
|
||||||
msg($this->getLang('saved'), 1);
|
msg($this->getLang('saved'), 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -223,6 +228,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
echo '<input type="text" class="edit" name="open_service_url" value="' . hsc((string)$this->getConf('open_service_url')) . '" />';
|
echo '<input type="text" class="edit" name="open_service_url" value="' . hsc((string)$this->getConf('open_service_url')) . '" />';
|
||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// pagelink_search_depth
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
||||||
|
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
|
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
|
||||||
|
|
||||||
echo '</fieldset>';
|
echo '</fieldset>';
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ $conf['gallery_thumb_scale'] = 1;
|
|||||||
// Local client service used by {{open>...}}.
|
// Local client service used by {{open>...}}.
|
||||||
$conf['open_service_url'] = 'http://127.0.0.1:8765';
|
$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
|
// Image syntax defaults
|
||||||
$conf['default_image_width'] = 250;
|
$conf['default_image_width'] = 250;
|
||||||
$conf['default_image_align'] = 'right'; // left|right|center
|
$conf['default_image_align'] = 'right'; // left|right|center
|
||||||
|
|||||||
5
images/pagelink.svg
Normal file
5
images/pagelink.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1.5 1.5" />
|
||||||
|
<path d="M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7L6.5 11.5" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
169
js/page-link.js
Normal file
169
js/page-link.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* 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 requestPageLink(cmd, params) {
|
||||||
|
var pageId = getPageId();
|
||||||
|
if (!pageId) return false;
|
||||||
|
|
||||||
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
|
|
||||||
|
var payload = new window.URLSearchParams();
|
||||||
|
payload.set('cmd', cmd);
|
||||||
|
payload.set('id', pageId);
|
||||||
|
if (params && typeof params === 'object') {
|
||||||
|
Object.keys(params).forEach(function (key) {
|
||||||
|
payload.set(key, String(params[key]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
|
body: payload.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 request failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePageLink() {
|
||||||
|
return requestPageLink('ensure', { sectok: getSectok() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkPageLink() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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', attachCopyTargets, false);
|
||||||
|
})();
|
||||||
@@ -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).";
|
"Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).";
|
||||||
$lang["open_service_url"] =
|
$lang["open_service_url"] =
|
||||||
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
|
"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_edit"] = "Scratchpad bearbeiten";
|
||||||
$lang["scratchpad_save"] = "Speichern";
|
$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_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_all_title"] = "Datums-Fix (Alle)";
|
||||||
|
$lang["toolbar_pagelink_title"] = "Seiten-Link";
|
||||||
|
$lang["pagelink_unlinked"] = "Seite nicht verknüpft (ID kopieren)";
|
||||||
|
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
|
||||||
|
|||||||
@@ -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["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["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).";
|
||||||
|
|||||||
@@ -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).";
|
"Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).";
|
||||||
$lang["open_service_url"] =
|
$lang["open_service_url"] =
|
||||||
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
|
"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_edit"] = "Edit scratchpad";
|
||||||
$lang["scratchpad_save"] = "Save";
|
$lang["scratchpad_save"] = "Save";
|
||||||
@@ -75,3 +77,6 @@ $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 (Copy ID)";
|
||||||
|
$lang["pagelink_multi_warning"] = "Multiple folders linked";
|
||||||
|
|||||||
@@ -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['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['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).';
|
||||||
|
|||||||
137
pagelink.php
Normal file
137
pagelink.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\PageLink;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
|
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
|
||||||
|
require_once(DOKU_INC . 'inc/init.php');
|
||||||
|
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
if (!$syntax) {
|
||||||
|
http_status(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => 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 === 'info') {
|
||||||
|
$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' => null,
|
||||||
|
'linked' => false,
|
||||||
|
'folder' => null,
|
||||||
|
'multiple' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = $pageLink->resolveUuid($uuid);
|
||||||
|
$folder = $info['folder'] ?? null;
|
||||||
|
$multiple = !empty($info['multiple']);
|
||||||
|
|
||||||
|
luxtools_pagelink_json(200, [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'linked' => is_string($folder) && $folder !== '',
|
||||||
|
'folder' => is_string($folder) ? $folder : null,
|
||||||
|
'multiple' => $multiple,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'unlink') {
|
||||||
|
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);
|
||||||
|
$result = $pageLink->unlinkPage($pageId);
|
||||||
|
|
||||||
|
luxtools_pagelink_json(200, [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => $result['uuid'] ?? null,
|
||||||
|
'folder' => $result['folder'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
luxtools_pagelink_json(400, ['ok' => false, 'error' => 'unknown command']);
|
||||||
351
src/PageLink.php
Normal file
351
src/PageLink.php
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
class PageLink
|
||||||
|
{
|
||||||
|
public const META_KEY = 'pagelink';
|
||||||
|
public const CACHE_FILE = 'pagelink_cache.json';
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $pathConfig;
|
||||||
|
|
||||||
|
/** @var int */
|
||||||
|
protected $maxDepth;
|
||||||
|
|
||||||
|
/** @var array|null */
|
||||||
|
protected $cache = null;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $cacheDirty = false;
|
||||||
|
|
||||||
|
public function __construct(string $pathConfig, int $maxDepth)
|
||||||
|
{
|
||||||
|
$this->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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the pagelink UUID from page metadata.
|
||||||
|
*/
|
||||||
|
public function removePageUuid(string $pageId): bool
|
||||||
|
{
|
||||||
|
if ($pageId === '') return false;
|
||||||
|
if (!function_exists('p_set_metadata')) return false;
|
||||||
|
|
||||||
|
return (bool)p_set_metadata($pageId, [self::META_KEY => '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a page: remove UUID, delete linked .pagelink file if present, and clear cache.
|
||||||
|
*
|
||||||
|
* @param string $pageId
|
||||||
|
* @return array{ok: bool, uuid: string|null, folder: string|null}
|
||||||
|
*/
|
||||||
|
public function unlinkPage(string $pageId): array
|
||||||
|
{
|
||||||
|
$uuid = $this->getPageUuid($pageId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
return ['ok' => true, 'uuid' => null, 'folder' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkInfo = $this->resolveUuid($uuid);
|
||||||
|
$folder = $linkInfo['folder'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($folder) && $folder !== '') {
|
||||||
|
$file = rtrim($folder, '/\\') . '/.pagelink';
|
||||||
|
if (is_file($file) && !is_link($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeCacheEntry($uuid);
|
||||||
|
$this->removePageUuid($pageId);
|
||||||
|
|
||||||
|
return ['ok' => true, 'uuid' => $uuid, 'folder' => is_string($folder) ? $folder : null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific UUID from cache.
|
||||||
|
*/
|
||||||
|
public function removeCacheEntry(string $uuid): void
|
||||||
|
{
|
||||||
|
$uuid = self::normalizeUuid($uuid);
|
||||||
|
if ($uuid === null) return;
|
||||||
|
|
||||||
|
$cache = $this->loadCache();
|
||||||
|
if (!isset($cache[$uuid])) return;
|
||||||
|
|
||||||
|
unset($cache[$uuid]);
|
||||||
|
$this->writeCache($cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
style.css
17
style.css
@@ -49,6 +49,23 @@ div.luxtools-plugin .luxtools-empty {
|
|||||||
padding: 0.25em 0;
|
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;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.3;
|
||||||
|
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;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Image gallery spacing. */
|
/* Image gallery spacing. */
|
||||||
div.luxtools-gallery {
|
div.luxtools-gallery {
|
||||||
padding-bottom: 0.5em;
|
padding-bottom: 0.5em;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use dokuwiki\Extension\SyntaxPlugin;
|
|||||||
use dokuwiki\plugin\luxtools\Crawler;
|
use dokuwiki\plugin\luxtools\Crawler;
|
||||||
use dokuwiki\plugin\luxtools\Output;
|
use dokuwiki\plugin\luxtools\Output;
|
||||||
use dokuwiki\plugin\luxtools\Path;
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLink;
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
@@ -208,7 +209,16 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
|
|||||||
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
|
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$pathHelper = new Path($this->getConf('paths'));
|
$pathConfig = (string)$this->getConf('paths');
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '' && $this->isBlobsPath($basePath)) {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($blobsRoot !== '') {
|
||||||
|
$pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
|
||||||
|
}
|
||||||
|
$pathHelper = new Path($pathConfig);
|
||||||
return $pathHelper->getPathInfo($basePath);
|
return $pathHelper->getPathInfo($basePath);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->renderError($renderer, 'error_outsidejail');
|
$this->renderError($renderer, 'error_outsidejail');
|
||||||
@@ -216,6 +226,99 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given path uses the blobs alias.
|
||||||
|
*/
|
||||||
|
protected function isBlobsPath(string $path): bool
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($path, '/');
|
||||||
|
return preg_match('/^blobs(\/|$)/', $trimmed) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Page not linked" message with copy ID affordance.
|
||||||
|
*/
|
||||||
|
protected function renderPageNotLinked(\Doku_Renderer $renderer): void
|
||||||
|
{
|
||||||
|
$uuid = $this->getPageUuidSafe();
|
||||||
|
$text = (string)$this->getLang('pagelink_unlinked');
|
||||||
|
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<a href="#" class="luxtools-pagelink-copy" data-luxtools-pagelink-copy="1"'
|
||||||
|
. ' data-uuid="' . hsc($uuid) . '"'
|
||||||
|
. '>' . hsc($text) . '</a>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata('[n/a: ' . $text . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current page UUID (if any).
|
||||||
|
*/
|
||||||
|
protected function getPageUuidSafe(): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
$depth = (int)$this->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
return $uuid ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Create and configure a Crawler instance.
|
||||||
*
|
*
|
||||||
|
|||||||
104
syntax/image.php
104
syntax/image.php
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use dokuwiki\Extension\SyntaxPlugin;
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
use dokuwiki\plugin\luxtools\Path;
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLink;
|
||||||
use dokuwiki\plugin\luxtools\ThumbnailHelper;
|
use dokuwiki\plugin\luxtools\ThumbnailHelper;
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
@@ -132,7 +133,18 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pathHelper = new Path($this->getConf('paths'));
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '' && $this->isBlobsPath($data['path'] ?? '')) {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathConfig = (string)$this->getConf('paths');
|
||||||
|
if ($blobsRoot !== '') {
|
||||||
|
$pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathHelper = new Path($pathConfig);
|
||||||
// Use addTrailingSlash=false since this is a file path, not a directory
|
// Use addTrailingSlash=false since this is a file path, not a directory
|
||||||
$pathInfo = $pathHelper->getPathInfo($data['path'], false);
|
$pathInfo = $pathHelper->getPathInfo($data['path'], false);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -214,6 +226,96 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
|
|||||||
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
|
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given path uses the blobs alias.
|
||||||
|
*/
|
||||||
|
protected function isBlobsPath(string $path): bool
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($path, '/');
|
||||||
|
return preg_match('/^blobs(\/|$)/', $trimmed) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Page not linked" message with copy ID affordance.
|
||||||
|
*/
|
||||||
|
protected function renderPageNotLinked(\Doku_Renderer $renderer): void
|
||||||
|
{
|
||||||
|
$uuid = $this->getPageUuidSafe();
|
||||||
|
$text = (string)$this->getLang('pagelink_unlinked');
|
||||||
|
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<a href="#" class="luxtools-pagelink-copy" data-luxtools-pagelink-copy="1"'
|
||||||
|
. ' data-uuid="' . hsc($uuid) . '"'
|
||||||
|
. '>' . hsc($text) . '</a>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata('[n/a: ' . $text . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current page UUID (if any).
|
||||||
|
*/
|
||||||
|
protected function getPageUuidSafe(): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
$depth = (int)$this->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
return $uuid ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Render the imagebox HTML.
|
||||||
*
|
*
|
||||||
|
|||||||
116
syntax/open.php
116
syntax/open.php
@@ -1,6 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use dokuwiki\Extension\SyntaxPlugin;
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLink;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* luxtools Plugin: Open local path syntax.
|
* luxtools Plugin: Open local path syntax.
|
||||||
@@ -73,6 +77,41 @@ class syntax_plugin_luxtools_open extends SyntaxPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve blobs alias to the linked folder (if available)
|
||||||
|
if ($this->isBlobsPath($path)) {
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '') {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pathConfig = (string)$this->getConf('paths');
|
||||||
|
$pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
|
||||||
|
$pathHelper = new Path($pathConfig);
|
||||||
|
$resolvedPath = $path;
|
||||||
|
$isBlobsRoot = (rtrim($resolvedPath, '/') === 'blobs');
|
||||||
|
if ($isBlobsRoot) {
|
||||||
|
$resolvedPath = rtrim($resolvedPath, '/') . '/';
|
||||||
|
}
|
||||||
|
$pathInfo = $pathHelper->getPathInfo($resolvedPath, $isBlobsRoot);
|
||||||
|
$path = $pathInfo['path'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map local paths back to their configured aliases before opening.
|
||||||
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)) {
|
||||||
|
try {
|
||||||
|
$pathHelper = new Path((string)$this->getConf('paths'));
|
||||||
|
$path = $pathHelper->mapToAliasPath($path);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// ignore mapping failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$serviceUrl = trim((string)$this->getConf('open_service_url'));
|
$serviceUrl = trim((string)$this->getConf('open_service_url'));
|
||||||
$serviceToken = trim((string)$this->getConf('open_service_token'));
|
$serviceToken = trim((string)$this->getConf('open_service_token'));
|
||||||
|
|
||||||
@@ -106,4 +145,81 @@ class syntax_plugin_luxtools_open extends SyntaxPlugin
|
|||||||
$renderer->doc .= $renderer->_formatLink($link);
|
$renderer->doc .= $renderer->_formatLink($link);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given path uses the blobs alias.
|
||||||
|
*/
|
||||||
|
protected function isBlobsPath(string $path): bool
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($path, '/');
|
||||||
|
return preg_match('/^blobs(\/|$)/', $trimmed) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current page's pagelink folder for the blobs alias.
|
||||||
|
*/
|
||||||
|
protected function resolveBlobsRoot(): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
$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) return '';
|
||||||
|
|
||||||
|
$linkInfo = $pageLink->resolveUuid($uuid);
|
||||||
|
$folder = $linkInfo['folder'] ?? '';
|
||||||
|
if (!is_string($folder) || $folder === '') return '';
|
||||||
|
|
||||||
|
return $folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Page not linked" message with copy ID affordance.
|
||||||
|
*/
|
||||||
|
protected function renderPageNotLinked(\Doku_Renderer $renderer): void
|
||||||
|
{
|
||||||
|
$uuid = $this->getPageUuidSafe();
|
||||||
|
$text = (string)$this->getLang('pagelink_unlinked');
|
||||||
|
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<a href="#" class="luxtools-pagelink-copy" data-luxtools-pagelink-copy="1"'
|
||||||
|
. ' data-uuid="' . hsc($uuid) . '"'
|
||||||
|
. '>' . hsc($text) . '</a>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata('[n/a: ' . $text . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current page UUID (if any).
|
||||||
|
*/
|
||||||
|
protected function getPageUuidSafe(): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
$depth = (int)$this->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
return $uuid ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user