Scratchpads V1
This commit is contained in:
11
.github/workflows/dokuwiki.yml
vendored
11
.github/workflows/dokuwiki.yml
vendored
@@ -1,11 +0,0 @@
|
||||
name: DokuWiki Default Tasks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '58 2 25 * *'
|
||||
|
||||
|
||||
jobs:
|
||||
all:
|
||||
uses: dokuwiki/github-action/.github/workflows/all.yml@main
|
||||
4
.github/workflows/instructions.md
vendored
Normal file
4
.github/workflows/instructions.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Development instructions
|
||||
|
||||
- Do not use `phpunit` There are missing dependencies that make it fail.
|
||||
- Consider The official documentation for wirting dokuwiki plugins: https://www.dokuwiki.org/devel:plugins
|
||||
15
README
15
README
@@ -12,6 +12,21 @@ will not work!
|
||||
Syntax:
|
||||
{{files>...}}
|
||||
|
||||
Scratchpad (shared, file-backed, no page revisions):
|
||||
{{scratchpad>...}}
|
||||
|
||||
The scratchpad is resolved via the "scratchpad_paths" setting.
|
||||
|
||||
Each scratchpad entry is a full filesystem path to a file (including extension),
|
||||
followed by an alias line that defines the pad name used in the wiki.
|
||||
|
||||
Example:
|
||||
/var/lib/dokuwiki-scratchpads/startpad.txt
|
||||
A> start
|
||||
|
||||
Then use:
|
||||
{{scratchpad>start}}
|
||||
|
||||
Please refer to http://www.dokuwiki.org/extensions for additional info
|
||||
on how to install extensions in DokuWiki.
|
||||
|
||||
|
||||
96
ScratchpadMap.php
Normal file
96
ScratchpadMap.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
/**
|
||||
* Maps scratchpad aliases (used in wiki pages) to full filesystem file paths.
|
||||
*
|
||||
* Config format (one per line):
|
||||
* /full/path/to/pad-file.txt
|
||||
* A> padname
|
||||
*
|
||||
* The next A> line assigns an alias for the previously listed file.
|
||||
*/
|
||||
class ScratchpadMap
|
||||
{
|
||||
/** @var array<string, array{alias:string, path:string}> */
|
||||
protected $map = [];
|
||||
|
||||
/**
|
||||
* @param string $config
|
||||
*/
|
||||
public function __construct($config)
|
||||
{
|
||||
$this->map = $this->parseConfig((string)$config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the given alias to a full file path.
|
||||
*
|
||||
* @param string $alias
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolve($alias)
|
||||
{
|
||||
$alias = trim((string)$alias);
|
||||
if ($alias === '') throw new \Exception('Empty alias');
|
||||
if (!isset($this->map[$alias])) {
|
||||
throw new \Exception('Unknown scratchpad alias');
|
||||
}
|
||||
return (string)$this->map[$alias]['path'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the parsed mapping.
|
||||
*
|
||||
* @return array<string, array{alias:string, path:string}>
|
||||
*/
|
||||
public function getMap()
|
||||
{
|
||||
return $this->map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $config
|
||||
* @return array<string, array{alias:string, path:string}>
|
||||
*/
|
||||
protected function parseConfig($config)
|
||||
{
|
||||
$map = [];
|
||||
$lines = explode("\n", (string)$config);
|
||||
|
||||
$lastFile = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') continue;
|
||||
|
||||
if (str_starts_with($line, 'A>')) {
|
||||
$alias = trim(substr($line, 2));
|
||||
if ($alias === '' || $lastFile === '') continue;
|
||||
$map[$alias] = [
|
||||
'alias' => $alias,
|
||||
'path' => $lastFile,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore W> lines for compatibility with the Path config style
|
||||
if (str_starts_with($line, 'W>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Treat as file path (no trailing slash enforced)
|
||||
$filePath = Path::cleanPath($line, false);
|
||||
if ($filePath === '' || str_ends_with($filePath, '/')) {
|
||||
// Ignore invalid entries; they will not be resolvable
|
||||
$lastFile = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastFile = $filePath;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
43
_test/ScratchpadMapTest.php
Normal file
43
_test/ScratchpadMapTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools\test;
|
||||
|
||||
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||
use DokuWikiTest;
|
||||
|
||||
/**
|
||||
* ScratchpadMap tests for the luxtools plugin
|
||||
*
|
||||
* @group plugin_luxtools
|
||||
* @group plugins
|
||||
*/
|
||||
class ScratchpadMapTest extends DokuWikiTest
|
||||
{
|
||||
public function testResolveAndParsing()
|
||||
{
|
||||
$map = new ScratchpadMap(
|
||||
<<<EOT
|
||||
/var/scratchpads/startpad.txt
|
||||
A> start
|
||||
|
||||
C:\\pads\\notes.md
|
||||
A> notes
|
||||
W> ignored
|
||||
|
||||
\\\\server\\share\\pads\\team.txt
|
||||
A> team
|
||||
EOT
|
||||
);
|
||||
|
||||
$this->assertEquals('/var/scratchpads/startpad.txt', $map->resolve('start'));
|
||||
$this->assertEquals('C:/pads/notes.md', $map->resolve('notes'));
|
||||
$this->assertEquals('\\\\server/share/pads/team.txt', $map->resolve('team'));
|
||||
}
|
||||
|
||||
public function testUnknownAliasThrows()
|
||||
{
|
||||
$map = new ScratchpadMap("/tmp/pad.txt\nA> pad\n");
|
||||
$this->expectExceptionMessageMatches('/Unknown scratchpad alias/');
|
||||
$map->resolve('nope');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
/** @var string[] */
|
||||
protected $configKeys = [
|
||||
'paths',
|
||||
'scratchpad_paths',
|
||||
'allow_in_comments',
|
||||
'defaults',
|
||||
'extensions',
|
||||
@@ -51,6 +52,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
$paths = $INPUT->str('paths');
|
||||
$paths = str_replace(["\r\n", "\r"], "\n", $paths);
|
||||
$newConf['paths'] = $paths;
|
||||
|
||||
$scratchpadPaths = $INPUT->str('scratchpad_paths');
|
||||
$scratchpadPaths = str_replace(["\r\n", "\r"], "\n", $scratchpadPaths);
|
||||
$newConf['scratchpad_paths'] = $scratchpadPaths;
|
||||
|
||||
$newConf['allow_in_comments'] = (int)$INPUT->bool('allow_in_comments');
|
||||
$newConf['defaults'] = $INPUT->str('defaults');
|
||||
$newConf['extensions'] = $INPUT->str('extensions');
|
||||
@@ -88,6 +94,12 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
echo '<textarea name="paths" rows="8" cols="80" class="edit">' . hsc($paths) . '</textarea>';
|
||||
echo '</label><br />';
|
||||
|
||||
// scratchpad_paths: multiline textarea
|
||||
$scratchpadPaths = (string)$this->getConf('scratchpad_paths');
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('scratchpad_paths')) . '</span><br />';
|
||||
echo '<textarea name="scratchpad_paths" rows="6" cols="80" class="edit">' . hsc($scratchpadPaths) . '</textarea>';
|
||||
echo '</label><br />';
|
||||
|
||||
// allow_in_comments
|
||||
$checked = $this->getConf('allow_in_comments') ? ' checked="checked"' : '';
|
||||
echo '<label class="block"><span>' . hsc($this->getLang('allow_in_comments')) . '</span> ';
|
||||
@@ -128,7 +140,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist plugin settings to conf/plugins/luxtools.local.php.
|
||||
* Persist plugin settings to conf/local.php.
|
||||
*
|
||||
* DokuWiki loads conf/local.php on each request; values written there will
|
||||
* be available via getConf(). We write into a dedicated BEGIN/END block so
|
||||
* updates are idempotent.
|
||||
*
|
||||
* @param array $newConf
|
||||
* @return bool
|
||||
@@ -138,30 +154,62 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||
if (!defined('DOKU_CONF')) return false;
|
||||
|
||||
$plugin = 'luxtools';
|
||||
$confDir = DOKU_CONF . 'plugins/';
|
||||
$file = $confDir . $plugin . '.local.php';
|
||||
$file = DOKU_CONF . 'local.php';
|
||||
|
||||
if (function_exists('io_mkdir_p')) {
|
||||
io_mkdir_p($confDir);
|
||||
} elseif (!@is_dir($confDir)) {
|
||||
@mkdir($confDir, 0777, true);
|
||||
$existing = '';
|
||||
if (@is_file($file) && @is_readable($file)) {
|
||||
$existing = (string)file_get_contents($file);
|
||||
}
|
||||
if ($existing === '') {
|
||||
$existing = "<?php\n";
|
||||
}
|
||||
if (!str_starts_with($existing, "<?php")) {
|
||||
// unexpected format - do not overwrite
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only write known keys; ignore extras.
|
||||
$lines = ["<?php"];
|
||||
$begin = "// BEGIN LUXTOOLS\n";
|
||||
$end = "// END LUXTOOLS\n";
|
||||
|
||||
// Build the block
|
||||
$lines = [$begin];
|
||||
foreach ($this->configKeys as $key) {
|
||||
if (!array_key_exists($key, $newConf)) continue;
|
||||
$value = $newConf[$key];
|
||||
$lines[] = '$conf[' . var_export($key, true) . '] = ' . $this->exportPhpValue($value, $key) . ';';
|
||||
$lines[] = '$conf[\'plugin\'][\'' . $plugin . '\'][' . var_export($key, true) . '] = ' . $this->exportPhpValue($value, $key) . ';';
|
||||
}
|
||||
$lines[] = '';
|
||||
$content = implode("\n", $lines);
|
||||
$lines[] = $end;
|
||||
$block = implode("\n", $lines);
|
||||
|
||||
// Replace or append the block in conf/local.php
|
||||
$beginPos = strpos($existing, $begin);
|
||||
if ($beginPos !== false) {
|
||||
$endPos = strpos($existing, $end, $beginPos);
|
||||
if ($endPos === false) {
|
||||
// malformed existing block - append a new one
|
||||
$content = rtrim($existing) . "\n\n" . $block;
|
||||
} else {
|
||||
$endPos += strlen($end);
|
||||
$content = substr($existing, 0, $beginPos) . $block . substr($existing, $endPos);
|
||||
}
|
||||
} else {
|
||||
$content = rtrim($existing) . "\n\n" . $block;
|
||||
}
|
||||
|
||||
$ok = false;
|
||||
if (function_exists('io_saveFile')) {
|
||||
return (bool)io_saveFile($file, $content);
|
||||
$ok = (bool)io_saveFile($file, $content);
|
||||
} else {
|
||||
$ok = @file_put_contents($file, $content, LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
return @file_put_contents($file, $content, LOCK_EX) !== false;
|
||||
// Best-effort cleanup: stop creating/using legacy conf/plugins/luxtools.local.php
|
||||
$legacy = DOKU_CONF . 'plugins/' . $plugin . '.local.php';
|
||||
if (@is_file($legacy)) {
|
||||
@unlink($legacy);
|
||||
}
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
$conf['paths'] = '';
|
||||
$conf['scratchpad_paths'] = '';
|
||||
$conf['allow_in_comments'] = 0;
|
||||
$conf['defaults'] = '';
|
||||
$conf['extensions'] = '';
|
||||
|
||||
@@ -22,7 +22,7 @@ $lang['settings'] = 'luxtools-Einstellungen';
|
||||
$lang['legend'] = 'Einstellungen';
|
||||
$lang['btn_save'] = 'Speichern';
|
||||
$lang['saved'] = 'Einstellungen gespeichert.';
|
||||
$lang['err_save'] = 'Einstellungen konnten nicht gespeichert werden. Bitte Schreibrechte für conf/plugins/ prüfen.';
|
||||
$lang['err_save'] = 'Einstellungen konnten nicht gespeichert werden. Bitte Schreibrechte für conf/local.php prüfen.';
|
||||
$lang['err_security'] = 'Sicherheits-Token ungültig. Bitte erneut versuchen.';
|
||||
|
||||
$lang['paths'] = 'Erlaubte Basis-Pfade (eine pro Zeile oder komma-separiert).';
|
||||
|
||||
@@ -22,13 +22,21 @@ $lang['settings'] = 'luxtools settings';
|
||||
$lang['legend'] = 'Settings';
|
||||
$lang['btn_save'] = 'Save';
|
||||
$lang['saved'] = 'Settings saved.';
|
||||
$lang['err_save'] = 'Could not save settings. Please check write permissions for conf/plugins/.';
|
||||
$lang['err_save'] = 'Could not save settings. Please check write permissions for conf/local.php.';
|
||||
$lang['err_security'] = 'Security token mismatch. Please retry.';
|
||||
|
||||
$lang['paths'] = 'Allowed base paths (one per line or comma-separated).';
|
||||
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension. Use a following A> line to set the pad name used in the wiki.';
|
||||
$lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in comments.';
|
||||
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration.';
|
||||
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list.';
|
||||
$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder.';
|
||||
$lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).';
|
||||
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).';
|
||||
|
||||
$lang['scratchpad_edit'] = 'Edit scratchpad';
|
||||
$lang['scratchpad_save'] = 'Save';
|
||||
$lang['scratchpad_cancel'] = 'Cancel';
|
||||
$lang['scratchpad_err_nopath'] = 'Scratchpad path missing';
|
||||
$lang['scratchpad_err_badpath'] = 'Invalid scratchpad path';
|
||||
$lang['scratchpad_err_unknown'] = 'Unknown scratchpad pad name';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
$lang['paths'] = 'Allowed base paths (one per line or comma-separated).';
|
||||
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension; use a following A> line to set the pad name used in the wiki.';
|
||||
$lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in comments.';
|
||||
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration';
|
||||
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
|
||||
|
||||
117
scratchpad.php
Normal file
117
scratchpad.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||
|
||||
use dokuwiki\plugin\luxtools\Path;
|
||||
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||
|
||||
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_scratchpad_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');
|
||||
$pad = (string)$INPUT->str('pad');
|
||||
$pageId = (string)$INPUT->str('id');
|
||||
if (function_exists('cleanID')) {
|
||||
$pageId = cleanID($pageId);
|
||||
}
|
||||
|
||||
if ($cmd === '' || $pad === '' || $pageId === '') {
|
||||
luxtools_scratchpad_json(400, ['ok' => false, 'error' => 'missing parameters']);
|
||||
}
|
||||
|
||||
// Require the user to at least be able to read the host page.
|
||||
if (function_exists('auth_quickaclcheck') && auth_quickaclcheck($pageId) < AUTH_READ) {
|
||||
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||
}
|
||||
|
||||
$map = new ScratchpadMap((string)$syntax->getConf('scratchpad_paths'));
|
||||
|
||||
try {
|
||||
$resolved = (string)$map->resolve($pad);
|
||||
|
||||
if ($resolved === '' || str_ends_with($resolved, '/')) {
|
||||
throw new Exception('Invalid scratchpad path');
|
||||
}
|
||||
|
||||
// Never allow writing/reading within DokuWiki-controlled paths.
|
||||
if (Path::isWikiControlled(Path::cleanPath($resolved, false))) {
|
||||
throw new Exception('Access to wiki files is not allowed');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'access denied']);
|
||||
}
|
||||
|
||||
if ($cmd === 'load') {
|
||||
$text = '';
|
||||
if (@is_file($resolved) && @is_readable($resolved)) {
|
||||
$text = (string)io_readFile($resolved, false);
|
||||
}
|
||||
luxtools_scratchpad_json(200, ['ok' => true, 'text' => $text]);
|
||||
}
|
||||
|
||||
if ($cmd === 'save') {
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
luxtools_scratchpad_json(405, ['ok' => false, 'error' => 'method not allowed']);
|
||||
}
|
||||
|
||||
// Require edit permission on the host page.
|
||||
if (function_exists('auth_quickaclcheck') && auth_quickaclcheck($pageId) < AUTH_EDIT) {
|
||||
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||
}
|
||||
|
||||
if (!checkSecurityToken()) {
|
||||
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'bad token']);
|
||||
}
|
||||
|
||||
$text = (string)$INPUT->str('text');
|
||||
|
||||
// Ensure directory exists
|
||||
$dir = dirname($resolved);
|
||||
if (function_exists('io_mkdir_p')) {
|
||||
io_mkdir_p($dir);
|
||||
} elseif (!@is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
$ok = false;
|
||||
if (function_exists('io_saveFile')) {
|
||||
$ok = (bool)io_saveFile($resolved, $text);
|
||||
} else {
|
||||
$ok = @file_put_contents($resolved, $text, LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
if (!$ok) {
|
||||
luxtools_scratchpad_json(500, ['ok' => false, 'error' => 'save failed']);
|
||||
}
|
||||
|
||||
luxtools_scratchpad_json(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
luxtools_scratchpad_json(400, ['ok' => false, 'error' => 'unknown command']);
|
||||
137
script.js
137
script.js
@@ -284,6 +284,142 @@
|
||||
return path;
|
||||
}
|
||||
|
||||
function initScratchpads() {
|
||||
var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]');
|
||||
if (!pads || !pads.length) return;
|
||||
|
||||
function setStatus(root, msg) {
|
||||
var el = root.querySelector('.luxtools-scratchpad-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
}
|
||||
|
||||
function getSectok() {
|
||||
try {
|
||||
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
|
||||
} catch (e) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function loadPad(root) {
|
||||
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
|
||||
var pad = (root.getAttribute('data-pad') || '').trim();
|
||||
var pageId = (root.getAttribute('data-pageid') || '').trim();
|
||||
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
|
||||
|
||||
var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId);
|
||||
return window.fetch(url, {
|
||||
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) {
|
||||
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body.text || '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function savePad(root, text) {
|
||||
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
|
||||
var pad = (root.getAttribute('data-pad') || '').trim();
|
||||
var pageId = (root.getAttribute('data-pageid') || '').trim();
|
||||
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
|
||||
|
||||
var params = new window.URLSearchParams();
|
||||
params.set('cmd', 'save');
|
||||
params.set('pad', pad);
|
||||
params.set('id', pageId);
|
||||
params.set('text', text || '');
|
||||
params.set('sectok', getSectok());
|
||||
|
||||
return window.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: params.toString()
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () { return null; }).then(function (body) {
|
||||
if (!res.ok || !body || body.ok !== true) {
|
||||
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openEditor(root) {
|
||||
var editor = root.querySelector('.luxtools-scratchpad-editor');
|
||||
var textarea = root.querySelector('textarea.luxtools-scratchpad-text');
|
||||
if (!editor || !textarea) return;
|
||||
|
||||
editor.hidden = false;
|
||||
setStatus(root, 'Loading…');
|
||||
textarea.disabled = true;
|
||||
|
||||
loadPad(root).then(function (text) {
|
||||
textarea.value = text;
|
||||
textarea.disabled = false;
|
||||
setStatus(root, '');
|
||||
textarea.focus();
|
||||
}).catch(function (e) {
|
||||
textarea.disabled = false;
|
||||
setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error'));
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditor(root) {
|
||||
var editor = root.querySelector('.luxtools-scratchpad-editor');
|
||||
if (!editor) return;
|
||||
editor.hidden = true;
|
||||
setStatus(root, '');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
|
||||
var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null;
|
||||
if (edit) {
|
||||
var root = edit.closest('div.luxtools-scratchpad');
|
||||
if (!root) return;
|
||||
e.preventDefault();
|
||||
openEditor(root);
|
||||
return;
|
||||
}
|
||||
|
||||
var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null;
|
||||
if (save) {
|
||||
var rootS = save.closest('div.luxtools-scratchpad');
|
||||
if (!rootS) return;
|
||||
e.preventDefault();
|
||||
var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text');
|
||||
if (!textareaS) return;
|
||||
textareaS.disabled = true;
|
||||
setStatus(rootS, 'Saving…');
|
||||
savePad(rootS, textareaS.value).then(function () {
|
||||
setStatus(rootS, 'Saved. Reloading…');
|
||||
try { window.location.reload(); } catch (err) {}
|
||||
}).catch(function (err) {
|
||||
textareaS.disabled = false;
|
||||
setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null;
|
||||
if (cancel) {
|
||||
var rootC = cancel.closest('div.luxtools-scratchpad');
|
||||
if (!rootC) return;
|
||||
e.preventDefault();
|
||||
closeEditor(rootC);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function findOpenElement(target) {
|
||||
var el = target;
|
||||
while (el && el !== document) {
|
||||
@@ -350,4 +486,5 @@
|
||||
|
||||
document.addEventListener('click', onClick, false);
|
||||
document.addEventListener('DOMContentLoaded', initGalleryThumbs, false);
|
||||
document.addEventListener('DOMContentLoaded', initScratchpads, false);
|
||||
})();
|
||||
|
||||
48
style.css
48
style.css
@@ -96,6 +96,54 @@ div.luxtools-gallery .luxtools-gallery-caption {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Scratchpad */
|
||||
div.luxtools-scratchpad {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad .luxtools-scratchpad-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad a.luxtools-scratchpad-edit {
|
||||
text-decoration: none;
|
||||
opacity: 0.7;
|
||||
padding: 0 0.2em;
|
||||
}
|
||||
div.luxtools-scratchpad a.luxtools-scratchpad-edit:hover,
|
||||
div.luxtools-scratchpad a.luxtools-scratchpad-edit:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad .luxtools-scratchpad-editor {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad textarea.luxtools-scratchpad-text {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 8em;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad .luxtools-scratchpad-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad .luxtools-scratchpad-status {
|
||||
opacity: 0.8;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
div.luxtools-scratchpad .luxtools-scratchpad-error {
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Disable background scrolling while the lightbox is open. */
|
||||
html.luxtools-noscroll,
|
||||
html.luxtools-noscroll body {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require_once(__DIR__ . '/syntax/AbstractSyntax.php');
|
||||
require_once(__DIR__ . '/syntax/files.php');
|
||||
require_once(__DIR__ . '/syntax/scratchpad.php');
|
||||
|
||||
/**
|
||||
* luxtools plugin bootstrap.
|
||||
|
||||
183
syntax/scratchpad.php
Normal file
183
syntax/scratchpad.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use dokuwiki\Extension\SyntaxPlugin;
|
||||
use dokuwiki\plugin\luxtools\Path;
|
||||
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||
|
||||
/**
|
||||
* luxtools Plugin: Scratchpad syntax.
|
||||
*
|
||||
* Renders the contents of a configured file as wikitext and provides a minimal
|
||||
* inline editor that saves directly to that file (no page revisions).
|
||||
*/
|
||||
class syntax_plugin_luxtools_scratchpad extends SyntaxPlugin
|
||||
{
|
||||
/** @inheritdoc */
|
||||
public function getType()
|
||||
{
|
||||
return 'substition';
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function getPType()
|
||||
{
|
||||
return 'block';
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function getSort()
|
||||
{
|
||||
// After most formatting, similar to other luxtools syntaxes
|
||||
return 223;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function connectTo($mode)
|
||||
{
|
||||
$this->Lexer->addSpecialPattern('\{\{scratchpad>.+?\}\}', $mode, 'plugin_luxtools_scratchpad');
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||
{
|
||||
global $INPUT;
|
||||
|
||||
// Do not allow the syntax in discussion plugin comments
|
||||
if (!$this->getConf('allow_in_comments') && $INPUT->has('comment')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$match = substr($match, strlen('{{scratchpad>'), -2);
|
||||
[$path,] = array_pad(explode('&', $match, 2), 2, '');
|
||||
|
||||
return [
|
||||
'path' => trim((string)$path),
|
||||
];
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function render($format, Doku_Renderer $renderer, $data)
|
||||
{
|
||||
if ($data === false) return false;
|
||||
if (!is_array($data)) return false;
|
||||
|
||||
if ($format !== 'xhtml' && $format !== 'odt') return false;
|
||||
|
||||
// Always disable caching: the scratchpad is external to page revisions.
|
||||
$renderer->nocache();
|
||||
|
||||
$rawPad = (string)($data['path'] ?? '');
|
||||
if ($rawPad === '') {
|
||||
$this->renderError($renderer, 'scratchpad_err_nopath');
|
||||
return true;
|
||||
}
|
||||
|
||||
$pathInfo = $this->getScratchpadPathInfoSafe($rawPad, $renderer);
|
||||
if ($pathInfo === false) return true;
|
||||
|
||||
$filePath = (string)($pathInfo['path'] ?? '');
|
||||
if ($filePath === '' || str_ends_with($filePath, '/')) {
|
||||
$this->renderError($renderer, 'scratchpad_err_badpath');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Never allow writing/reading within DokuWiki-controlled paths
|
||||
if (Path::isWikiControlled(Path::cleanPath($filePath, false))) {
|
||||
$this->renderError($renderer, 'error_outsidejail');
|
||||
return true;
|
||||
}
|
||||
|
||||
$text = '';
|
||||
if (@is_file($filePath) && @is_readable($filePath)) {
|
||||
$text = (string)io_readFile($filePath, false);
|
||||
}
|
||||
|
||||
if ($format === 'odt') {
|
||||
$renderer->cdata($text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var Doku_Renderer_xhtml $renderer */
|
||||
$endpoint = DOKU_BASE . 'lib/plugins/luxtools/scratchpad.php';
|
||||
|
||||
global $ID;
|
||||
$pageId = (string)$ID;
|
||||
$canEdit = function_exists('auth_quickaclcheck') ? (auth_quickaclcheck($pageId) >= AUTH_EDIT) : false;
|
||||
|
||||
$renderer->doc .= '<div class="luxtools-plugin luxtools-scratchpad"'
|
||||
. ' data-luxtools-scratchpad="1"'
|
||||
. ' data-endpoint="' . hsc($endpoint) . '"'
|
||||
. ' data-pad="' . hsc($rawPad) . '"'
|
||||
. ' data-pageid="' . hsc($pageId) . '"'
|
||||
. '>';
|
||||
|
||||
$renderer->doc .= '<div class="luxtools-scratchpad-bar">';
|
||||
if ($canEdit) {
|
||||
$label = (string)$this->getLang('scratchpad_edit');
|
||||
if ($label === '') $label = 'Edit';
|
||||
$renderer->doc .= '<a href="#" class="luxtools-scratchpad-edit" title="' . hsc($label) . '" aria-label="' . hsc($label) . '">✎</a>';
|
||||
}
|
||||
$renderer->doc .= '</div>';
|
||||
|
||||
$renderer->doc .= '<div class="luxtools-scratchpad-view">';
|
||||
$renderer->doc .= $this->renderWikitextFragment($text);
|
||||
$renderer->doc .= '</div>';
|
||||
|
||||
if ($canEdit) {
|
||||
$renderer->doc .= '<div class="luxtools-scratchpad-editor" hidden>';
|
||||
$renderer->doc .= '<textarea class="luxtools-scratchpad-text" rows="10" spellcheck="true"></textarea>';
|
||||
$renderer->doc .= '<div class="luxtools-scratchpad-actions">'
|
||||
. '<button type="button" class="button luxtools-scratchpad-save">' . hsc((string)$this->getLang('scratchpad_save') ?: 'Save') . '</button>'
|
||||
. '<button type="button" class="button luxtools-scratchpad-cancel">' . hsc((string)$this->getLang('scratchpad_cancel') ?: 'Cancel') . '</button>'
|
||||
. '<span class="luxtools-scratchpad-status" aria-live="polite"></span>'
|
||||
. '</div>';
|
||||
$renderer->doc .= '</div>';
|
||||
}
|
||||
|
||||
$renderer->doc .= '</div>';
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function renderWikitextFragment(string $text): string
|
||||
{
|
||||
// Render wikitext to XHTML and return it as a string
|
||||
$info = ['cache' => false];
|
||||
$instructions = p_get_instructions($text);
|
||||
return (string)p_render('xhtml', $instructions, $info);
|
||||
}
|
||||
|
||||
protected function renderError(Doku_Renderer $renderer, string $langKey): void
|
||||
{
|
||||
$msg = (string)$this->getLang($langKey);
|
||||
if ($msg === '') $msg = $langKey;
|
||||
|
||||
if ($renderer instanceof Doku_Renderer_xhtml) {
|
||||
$renderer->doc .= '<div class="luxtools-plugin luxtools-scratchpad"><div class="luxtools-scratchpad-error">'
|
||||
. hsc($msg)
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$renderer->cdata('[n/a: ' . $msg . ']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a scratchpad alias to its file path using the configured scratchpad_paths setting.
|
||||
*
|
||||
* @param string $pad
|
||||
* @param Doku_Renderer $renderer
|
||||
* @return array|false
|
||||
*/
|
||||
protected function getScratchpadPathInfoSafe(string $pad, Doku_Renderer $renderer)
|
||||
{
|
||||
try {
|
||||
$map = new ScratchpadMap((string)$this->getConf('scratchpad_paths'));
|
||||
return [
|
||||
'path' => $map->resolve($pad),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$this->renderError($renderer, 'scratchpad_err_unknown');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user