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:
|
Syntax:
|
||||||
{{files>...}}
|
{{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
|
Please refer to http://www.dokuwiki.org/extensions for additional info
|
||||||
on how to install extensions in DokuWiki.
|
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[] */
|
/** @var string[] */
|
||||||
protected $configKeys = [
|
protected $configKeys = [
|
||||||
'paths',
|
'paths',
|
||||||
|
'scratchpad_paths',
|
||||||
'allow_in_comments',
|
'allow_in_comments',
|
||||||
'defaults',
|
'defaults',
|
||||||
'extensions',
|
'extensions',
|
||||||
@@ -51,6 +52,11 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
$paths = $INPUT->str('paths');
|
$paths = $INPUT->str('paths');
|
||||||
$paths = str_replace(["\r\n", "\r"], "\n", $paths);
|
$paths = str_replace(["\r\n", "\r"], "\n", $paths);
|
||||||
$newConf['paths'] = $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['allow_in_comments'] = (int)$INPUT->bool('allow_in_comments');
|
||||||
$newConf['defaults'] = $INPUT->str('defaults');
|
$newConf['defaults'] = $INPUT->str('defaults');
|
||||||
$newConf['extensions'] = $INPUT->str('extensions');
|
$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 '<textarea name="paths" rows="8" cols="80" class="edit">' . hsc($paths) . '</textarea>';
|
||||||
echo '</label><br />';
|
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
|
// allow_in_comments
|
||||||
$checked = $this->getConf('allow_in_comments') ? ' checked="checked"' : '';
|
$checked = $this->getConf('allow_in_comments') ? ' checked="checked"' : '';
|
||||||
echo '<label class="block"><span>' . hsc($this->getLang('allow_in_comments')) . '</span> ';
|
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
|
* @param array $newConf
|
||||||
* @return bool
|
* @return bool
|
||||||
@@ -138,30 +154,62 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
if (!defined('DOKU_CONF')) return false;
|
if (!defined('DOKU_CONF')) return false;
|
||||||
|
|
||||||
$plugin = 'luxtools';
|
$plugin = 'luxtools';
|
||||||
$confDir = DOKU_CONF . 'plugins/';
|
$file = DOKU_CONF . 'local.php';
|
||||||
$file = $confDir . $plugin . '.local.php';
|
|
||||||
|
|
||||||
if (function_exists('io_mkdir_p')) {
|
$existing = '';
|
||||||
io_mkdir_p($confDir);
|
if (@is_file($file) && @is_readable($file)) {
|
||||||
} elseif (!@is_dir($confDir)) {
|
$existing = (string)file_get_contents($file);
|
||||||
@mkdir($confDir, 0777, true);
|
}
|
||||||
|
if ($existing === '') {
|
||||||
|
$existing = "<?php\n";
|
||||||
|
}
|
||||||
|
if (!str_starts_with($existing, "<?php")) {
|
||||||
|
// unexpected format - do not overwrite
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only write known keys; ignore extras.
|
$begin = "// BEGIN LUXTOOLS\n";
|
||||||
$lines = ["<?php"];
|
$end = "// END LUXTOOLS\n";
|
||||||
|
|
||||||
|
// Build the block
|
||||||
|
$lines = [$begin];
|
||||||
foreach ($this->configKeys as $key) {
|
foreach ($this->configKeys as $key) {
|
||||||
if (!array_key_exists($key, $newConf)) continue;
|
if (!array_key_exists($key, $newConf)) continue;
|
||||||
$value = $newConf[$key];
|
$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[] = '';
|
$lines[] = $end;
|
||||||
$content = implode("\n", $lines);
|
$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')) {
|
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['paths'] = '';
|
||||||
|
$conf['scratchpad_paths'] = '';
|
||||||
$conf['allow_in_comments'] = 0;
|
$conf['allow_in_comments'] = 0;
|
||||||
$conf['defaults'] = '';
|
$conf['defaults'] = '';
|
||||||
$conf['extensions'] = '';
|
$conf['extensions'] = '';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ $lang['settings'] = 'luxtools-Einstellungen';
|
|||||||
$lang['legend'] = 'Einstellungen';
|
$lang['legend'] = 'Einstellungen';
|
||||||
$lang['btn_save'] = 'Speichern';
|
$lang['btn_save'] = 'Speichern';
|
||||||
$lang['saved'] = 'Einstellungen gespeichert.';
|
$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['err_security'] = 'Sicherheits-Token ungültig. Bitte erneut versuchen.';
|
||||||
|
|
||||||
$lang['paths'] = 'Erlaubte Basis-Pfade (eine pro Zeile oder komma-separiert).';
|
$lang['paths'] = 'Erlaubte Basis-Pfade (eine pro Zeile oder komma-separiert).';
|
||||||
|
|||||||
@@ -22,13 +22,21 @@ $lang['settings'] = 'luxtools settings';
|
|||||||
$lang['legend'] = 'Settings';
|
$lang['legend'] = 'Settings';
|
||||||
$lang['btn_save'] = 'Save';
|
$lang['btn_save'] = 'Save';
|
||||||
$lang['saved'] = 'Settings saved.';
|
$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['err_security'] = 'Security token mismatch. Please retry.';
|
||||||
|
|
||||||
$lang['paths'] = 'Allowed base paths (one per line or comma-separated).';
|
$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['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['defaults'] = 'Default options. Use the same syntax as in inline configuration.';
|
||||||
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list.';
|
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list.';
|
||||||
$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder.';
|
$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['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['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
|
<?php
|
||||||
|
|
||||||
$lang['paths'] = 'Allowed base paths (one per line or comma-separated).';
|
$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['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['defaults'] = 'Default options. Use the same syntax as in inline configuration';
|
||||||
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
|
$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;
|
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) {
|
function findOpenElement(target) {
|
||||||
var el = target;
|
var el = target;
|
||||||
while (el && el !== document) {
|
while (el && el !== document) {
|
||||||
@@ -350,4 +486,5 @@
|
|||||||
|
|
||||||
document.addEventListener('click', onClick, false);
|
document.addEventListener('click', onClick, false);
|
||||||
document.addEventListener('DOMContentLoaded', initGalleryThumbs, 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;
|
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. */
|
/* Disable background scrolling while the lightbox is open. */
|
||||||
html.luxtools-noscroll,
|
html.luxtools-noscroll,
|
||||||
html.luxtools-noscroll body {
|
html.luxtools-noscroll body {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require_once(__DIR__ . '/syntax/AbstractSyntax.php');
|
require_once(__DIR__ . '/syntax/AbstractSyntax.php');
|
||||||
require_once(__DIR__ . '/syntax/files.php');
|
require_once(__DIR__ . '/syntax/files.php');
|
||||||
|
require_once(__DIR__ . '/syntax/scratchpad.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* luxtools plugin bootstrap.
|
* 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