Refactor project structure

This commit is contained in:
2026-01-09 11:45:27 +01:00
parent c11d9bdb8c
commit c20b482616
12 changed files with 51 additions and 1 deletions

336
src/Crawler.php Normal file
View File

@@ -0,0 +1,336 @@
<?php
namespace dokuwiki\plugin\luxtools;
class Crawler
{
/** @var string regexp to check extensions */
protected $ext;
/** @var string */
protected $sortby = 'name';
/** @var bool */
protected $sortreverse = false;
/** @var bool */
protected $foldersFirst = false;
/** @var string[] patterns to ignore */
protected $ignore = [];
/**
* Initializes the crawler
*
* @param string $extensions The extensions to allow (comma separated list)
*/
public function __construct($extensions)
{
$this->ext = explode(',', $extensions);
$this->ext = array_map('trim', $this->ext);
$this->ext = array_map('preg_quote_cb', $this->ext);
$this->ext = implode('|', $this->ext);
$this->ignore = $this->loadIgnores();
}
public function setSortBy($sortby)
{
$this->sortby = $sortby;
}
public function setSortReverse($sortreverse)
{
$this->sortreverse = $sortreverse;
}
public function setFoldersFirst($foldersFirst)
{
$this->foldersFirst = (bool)$foldersFirst;
}
/**
* Does a (recursive) crawl for finding files based on a given pattern.
* Based on a safe glob reimplementation using fnmatch and opendir.
*
* @param string $path the path to search in
* @param string $pattern the pattern to match to
* @param bool $recursive whether to search recursively
* @param string $titlefile the name of the title file
* @return array a hierarchical filelist or false if nothing could be found
*
* @see http://www.php.net/manual/en/function.glob.php#71083
*/
public function crawl($root, $local, $pattern, $recursive, $titlefile)
{
$path = $root . $local;
// do not descent into wiki or data directories
if (Path::isWikiControlled($path)) return [];
if (($dir = opendir($path)) === false) return [];
$result = [];
while (($file = readdir($dir)) !== false) {
if ($file[0] == '.' || $file == $titlefile) {
// ignore hidden, system and title files
continue;
}
$self = $local . '/' . $file;
$filepath = $path . '/' . $file;
if (!is_readable($filepath)) continue;
if ($this->fnmatch($pattern, $file) || (is_dir($filepath) && $recursive)) {
if (!is_dir($filepath) && !$this->isExtensionAllowed($file)) {
continue;
}
if ($this->isFileIgnored($file)) {
continue;
}
// get title file
$filename = $file;
if (is_dir($filepath)) {
$title = $filepath . '/' . $titlefile;
if (is_readable($title)) {
$filename = io_readFile($title, false);
}
}
// prepare entry
if (!is_dir($filepath) || $recursive) {
$entry = [
'name' => $filename,
'local' => $self,
'path' => $filepath,
'mtime' => filemtime($filepath),
'ctime' => filectime($filepath),
'size' => filesize($filepath),
'children' => ((is_dir($filepath) && $recursive) ?
$this->crawl($root, $self, $pattern, $recursive, $titlefile) :
false
),
'treesize' => 0,
];
// calculate tree size
if ($entry['children'] !== false) {
foreach ($entry['children'] as $child) {
$entry['treesize'] += $child['treesize'];
}
} else {
$entry['treesize'] = 1;
}
// add entry to result
$result[] = $entry;
}
}
}
closedir($dir);
return $this->sortItems($result);
}
/**
* List the direct children (files and directories) of a given local path.
*
* Unlike crawl(), this includes directories even when not recursing.
*
* @param string $root
* @param string $local
* @param string $titlefile
* @return array
*/
public function listDirectory($root, $local, $titlefile)
{
$path = $root . $local;
$path = rtrim($path, '/');
// do not list wiki or data directories
if (Path::isWikiControlled($path)) return [];
if (($dir = opendir($path)) === false) return [];
$result = [];
while (($file = readdir($dir)) !== false) {
if ($file[0] == '.' || $file == $titlefile) {
// ignore hidden, system and title files
continue;
}
$filepath = $path . '/' . $file;
if (!is_readable($filepath)) continue;
$isDir = is_dir($filepath);
if (!$isDir && !$this->isExtensionAllowed($file)) {
continue;
}
if ($this->isFileIgnored($file)) {
continue;
}
// get title file (directories only)
$filename = $file;
if ($isDir) {
$title = $filepath . '/' . $titlefile;
if (is_readable($title)) {
$filename = io_readFile($title, false);
}
}
// build a local path consistent with crawl() (leading slash for root)
$self = rtrim($local, '/') . '/' . $file;
if ($self === '/' . $file) {
// keep the original behaviour when local is empty
$self = '/' . $file;
}
$entry = [
'name' => $filename,
'local' => $self,
'path' => $filepath,
'mtime' => filemtime($filepath),
'ctime' => filectime($filepath),
'size' => $isDir ? 0 : filesize($filepath),
'children' => false,
'treesize' => 1,
'isdir' => $isDir,
];
$result[] = $entry;
}
closedir($dir);
return $this->sortItems($result);
}
/**
* Sort the given items by the current sortby and sortreverse settings
*
* @param array $items
* @return array
*/
protected function sortItems($items)
{
$callback = [$this, 'compare' . ucfirst($this->sortby)];
if (!is_callable($callback)) return $items;
// Optional grouping: keep directories before files.
// Implement reverse ordering by inverting comparisons instead of array_reverse(),
// so the directory-first grouping stays intact.
if ($this->foldersFirst) {
usort($items, function ($a, $b) use ($callback) {
$aIsDir = $this->isDirectoryItem($a);
$bIsDir = $this->isDirectoryItem($b);
if ($aIsDir !== $bIsDir) {
return $aIsDir ? -1 : 1;
}
$cmp = call_user_func($callback, $a, $b);
if ($this->sortreverse) $cmp = -$cmp;
return $cmp;
});
} else {
usort($items, $callback);
if ($this->sortreverse) {
$items = array_reverse($items);
}
}
return $items;
}
/**
* Detect whether an item represents a directory.
* Supports both crawl() results (children tree) and listDirectory() results (isdir).
*/
protected function isDirectoryItem($item)
{
if (!is_array($item)) return false;
if (!empty($item['isdir'])) return true;
return array_key_exists('children', $item) && $item['children'] !== false;
}
/**
* Check if a file is allowed by the configured extensions
*
* @param string $file
* @return bool
*/
protected function isExtensionAllowed($file)
{
if ($this->ext === '') return true; // no restriction
return preg_match('/(' . $this->ext . ')$/i', $file);
}
/**
* Check if a file is ignored by the ignore patterns
*
* @param string $file
* @return bool
*/
protected function isFileIgnored($file)
{
foreach ($this->ignore as $pattern) {
if ($this->fnmatch($pattern, $file)) return true;
}
return false;
}
/**
* Load the ignore patterns from the ignore.txt file
*
* @return string[]
*/
protected function loadIgnores()
{
$file = __DIR__ . '/../conf/ignore.txt';
$ignore = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$ignore = array_map(static fn($line) => trim(preg_replace('/\s*#.*$/', '', $line)), $ignore);
$ignore = array_filter($ignore);
return $ignore;
}
/**
* Replacement for fnmatch() for windows systems.
*
* @author jk at ricochetsolutions dot com
* @link http://www.php.net/manual/en/function.fnmatch.php#71725
*/
protected function fnmatch($pattern, $string)
{
return preg_match(
"#^" . strtr(
preg_quote($pattern, '#'),
[
'\*' => '.*',
'\?' => '.',
'\[' => '[',
'\]' => ']'
]
) . "$#i",
$string
);
}
public function compareName($a, $b)
{
return strcmp($a['name'], $b['name']);
}
public function compareIname($a, $b)
{
return strcmp(strtolower($a['name']), strtolower($b['name']));
}
public function compareCtime($a, $b)
{
return $a['ctime'] <=> $b['ctime'];
}
public function compareMtime($a, $b)
{
return $a['mtime'] <=> $b['mtime'];
}
public function compareSize($a, $b)
{
return $a['size'] <=> $b['size'];
}
}

638
src/Output.php Normal file
View File

@@ -0,0 +1,638 @@
<?php
namespace dokuwiki\plugin\luxtools;
class Output
{
/** @var \Doku_Renderer */
protected $renderer;
/** @var string */
protected $basedir;
/** @var string */
protected $webdir;
/** @var array */
protected $files;
/** @var Path|false|null */
protected $openPathMapper = null;
public function __construct(\Doku_Renderer $renderer, $basedir, $webdir, $files)
{
$this->renderer = $renderer;
$this->basedir = $basedir;
$this->webdir = $webdir;
$this->files = $files;
}
public function renderAsList($params)
{
$this->openContainer($params);
$this->renderListItems($this->files, $params);
$this->closeContainer();
}
/**
* Render a thumbnail gallery (XHTML only).
*
* Expects a flat list of file items in $this->files.
* Clicking a thumbnail opens the original image.
*
* @param array $params
* @return void
*/
public function renderAsGallery($params)
{
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
$params['style'] = 'list';
$this->renderAsList($params);
return;
}
$thumbW = 150;
$thumbH = 150;
$thumbQ = 80;
// Allow generating larger thumbnails (e.g. 2x) while still displaying them
// at 150x150 for sharper results on HiDPI screens.
$thumbScale = 1.0;
$syntax = plugin_load('syntax', 'luxtools');
if ($syntax) {
$rawScale = (string)$syntax->getConf('gallery_thumb_scale');
if ($rawScale !== '') {
$thumbScale = (float)$rawScale;
}
}
if (!is_finite($thumbScale) || $thumbScale < 1.0) $thumbScale = 1.0;
if ($thumbScale > 4.0) $thumbScale = 4.0;
$genThumbW = (int)max(1, (int)round($thumbW * $thumbScale));
$genThumbH = (int)max(1, (int)round($thumbH * $thumbScale));
$placeholderUrl = DOKU_BASE . 'lib/images/blank.gif';
$placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : '';
if ($placeholderId !== '' && function_exists('ml')) {
// ml() builds a fetch.php URL for a MediaManager item
$placeholderUrl = ml($placeholderId, ['w' => $genThumbW, 'h' => $genThumbH], true, '&');
}
/** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer;
$renderer->doc .= '<div class="luxtools-plugin luxtools-gallery" data-luxtools-gallery="1">';
foreach ($this->files as $item) {
$url = $this->itemWebUrl($item, !empty($params['randlinks']));
$thumbUrl = $this->withQueryParams($url, [
'thumb' => 1,
'w' => $genThumbW,
'h' => $genThumbH,
// Keep quality explicit so cache file naming stays stable.
'q' => $thumbQ,
]);
$safeUrl = hsc($url);
$safeThumbUrl = hsc($thumbUrl);
$safePlaceholderUrl = hsc($placeholderUrl);
$label = hsc($item['name']);
$caption = hsc(basename((string)($item['name'] ?? '')));
if ($caption === '') {
$caption = $label;
}
$initialSrc = $safePlaceholderUrl;
$dataThumb = ' data-thumb-src="' . $safeThumbUrl . '"';
$thumbCachePath = $this->thumbCachePathForItem($item, $genThumbW, $genThumbH, $thumbQ);
if (is_string($thumbCachePath) && $thumbCachePath !== '' && @is_file($thumbCachePath)) {
// Thumb already exists: start with it immediately (no JS swap needed)
$initialSrc = $safeThumbUrl;
$dataThumb = '';
}
$renderer->doc .= '<a'
. ' href="' . $safeUrl . '"'
. ' class="media luxtools-gallery-item"'
. ' title="' . $label . '"'
. ' aria-label="' . $label . '"'
. ' data-luxtools-full="' . $safeUrl . '"'
. ' data-luxtools-name="' . $caption . '"'
. '>';
$renderer->doc .= '<img'
. ' class="luxtools-thumb"'
. ' src="' . $initialSrc . '"'
. $dataThumb
. ' alt=""'
. ' width="' . $thumbW . '"'
. ' height="' . $thumbH . '"'
. ' loading="lazy"'
. ' decoding="async"'
. ' />';
$renderer->doc .= '<span class="luxtools-gallery-caption">' . $caption . '</span>';
$renderer->doc .= '</a>';
}
$renderer->doc .= '</div>';
}
/**
* Append query parameters to a URL.
*
* Preserves existing query and fragment and uses RFC3986 encoding.
*
* @param string $url
* @param array $params
* @return string
*/
protected function withQueryParams(string $url, array $params): string
{
if ($params === []) return $url;
$fragment = '';
$hashPos = strpos($url, '#');
if ($hashPos !== false) {
$fragment = substr($url, $hashPos);
$url = substr($url, 0, $hashPos);
}
$glue = (strpos($url, '?') === false) ? '?' : '&';
return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment;
}
/**
* Compute the expected thumbnail cache path for an item.
*
* Mirrors the hashing scheme in file.php so we can detect whether a thumb
* already exists and can be used immediately.
*
* @param array $item
* @param int $w
* @param int $h
* @param int $q
* @return string|null
*/
protected function thumbCachePathForItem(array $item, int $w, int $h, int $q): ?string
{
if (!isset($item['path']) || !is_string($item['path']) || $item['path'] === '') return null;
if (!isset($item['mtime'])) return null;
$path = $item['path'];
$mtime = (int)$item['mtime'];
// Decide output format the same way file.php does.
try {
[, $mime,] = mimetype($path, false);
} catch (\Throwable $e) {
return null;
}
if (!is_string($mime) || !str_starts_with($mime, 'image/')) return null;
$dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
global $conf;
if (!isset($conf['cachedir']) || !is_string($conf['cachedir']) || trim($conf['cachedir']) === '') return null;
$hash = sha1($path . '|' . $mtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
$sub = substr($hash, 0, 2);
$cacheDir = rtrim($conf['cachedir'], '/');
return $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
}
/**
* Renders the files as a table, including details if configured that way.
*
* @param array $params the parameters of the filelist call
*/
public function renderAsTable($params)
{
$this->openContainer($params);
$items = $this->flattenResultTree($this->files);
$this->renderTableItems($items, $params);
$this->closeContainer();
}
/**
* Render a flat list (files and/or directories) as a table.
*
* @param array $params
* @return void
*/
public function renderAsFlatTable($params)
{
$this->openContainer($params);
$this->renderTableItems($this->files, $params);
$this->closeContainer();
}
/**
* Open the wrapping container with an optional max-height and scroll behaviour.
*/
protected function openContainer($params): void
{
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
return;
}
$style = $this->containerStyle($params);
$this->renderer->doc .= '<div class="luxtools-plugin"' . $style . '>';
}
/**
* Close the wrapping container if XHTML renderer is in use.
*/
protected function closeContainer(): void
{
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
return;
}
$this->renderer->doc .= '</div>';
}
/**
* Build the inline style attribute for the container based on the maxheight param.
*/
protected function containerStyle($params): string
{
if (!isset($params['maxheight'])) {
return '';
}
$maxHeight = (int)$params['maxheight'];
if ($maxHeight < 0) {
return '';
}
return ' style="max-height: ' . $maxHeight . 'px; overflow-y: auto;"';
}
/**
* Renders the files as a table, including details if configured that way.
*
* @param array $params the parameters of the filelist call
*/
protected function renderTableItems($items, $params)
{
$renderer = $this->renderer;
// count the columns
$columns = 1;
if ($params['showsize']) {
$columns++;
}
if ($params['showdate']) {
$columns++;
}
$renderer->table_open($columns);
$hasOpenLocation = isset($params['openlocation']) && is_string($params['openlocation']) && trim($params['openlocation']) !== '';
$hasHeader = !empty($params['tableheader']);
if ($hasOpenLocation || $hasHeader) {
$renderer->tablethead_open();
// Small row above the header with an "Open Location" link.
if ($hasOpenLocation && ($renderer instanceof \Doku_Renderer_xhtml)) {
$openItem = [
'name' => $this->getLang('openlocation'),
'path' => $params['openlocation'],
'isdir' => true,
// Render without an icon (no mf_* class).
'noicon' => true,
];
/** @var \Doku_Renderer_xhtml $renderer */
$renderer->doc .= '<tr class="luxtools-openlocation-row"><td colspan="' . (int)$columns . '">';
$this->renderDirectoryLink($openItem);
$renderer->doc .= '</td></tr>';
}
if ($hasHeader) {
$renderer->tablerow_open();
$renderer->tableheader_open();
$renderer->cdata($this->getLang('filename'));
$renderer->tableheader_close();
if ($params['showsize']) {
$renderer->tableheader_open();
$renderer->cdata($this->getLang('filesize'));
$renderer->tableheader_close();
}
if ($params['showdate']) {
$renderer->tableheader_open();
$renderer->cdata($this->getLang('lastmodified'));
$renderer->tableheader_close();
}
$renderer->tablerow_close();
}
$renderer->tablethead_close();
}
$renderer->tabletbody_open();
foreach ($items as $item) {
$renderer->tablerow_open();
$renderer->tablecell_open();
$this->renderItemLink($item, $params['randlinks']);
$renderer->tablecell_close();
if ($params['showsize']) {
$renderer->tablecell_open(1, 'right');
if (!empty($item['isdir'])) {
$renderer->cdata('');
} else {
$renderer->cdata(filesize_h($item['size']));
}
$renderer->tablecell_close();
}
if ($params['showdate']) {
$renderer->tablecell_open();
$renderer->cdata(dformat($item['mtime']));
$renderer->tablecell_close();
}
$renderer->tablerow_close();
}
$renderer->tabletbody_close();
$renderer->table_close();
}
/**
* Recursively renders a tree of files as list items.
*
* @param array $items the files to render
* @param array $params the parameters of the filelist call
* @param int $level the level to render
* @return void
*/
protected function renderListItems($items, $params, $level = 1)
{
if ($params['style'] == 'olist') {
$this->renderer->listo_open();
} else {
$this->renderer->listu_open();
}
foreach ($items as $file) {
if ($file['children'] === false && $file['treesize'] === 0) continue; // empty directory
$this->renderer->listitem_open($level);
$this->renderer->listcontent_open();
if ($file['children'] !== false && $file['treesize'] > 0) {
// render the directory and its subtree
$this->renderer->cdata($file['name']);
$this->renderListItems($file['children'], $params, $level + 1);
} elseif ($file['children'] === false) {
// render the file link
$this->renderItemLink($file, $params['randlinks']);
// render filesize
if ($params['showsize']) {
$this->renderer->cdata($params['listsep'] . filesize_h($file['size']));
}
// render lastmodified
if ($params['showdate']) {
$this->renderer->cdata($params['listsep'] . dformat($file['mtime']));
}
}
$this->renderer->listcontent_close();
$this->renderer->listitem_close();
}
if ($params['style'] == 'olist') {
$this->renderer->listo_close();
} else {
$this->renderer->listu_close();
}
}
protected function renderItemLink($item, $cachebuster = false)
{
if (!empty($item['isdir'])) {
$this->renderDirectoryLink($item);
return;
}
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
$this->renderItemLinkXHTML($item, $cachebuster);
} else {
$this->renderItemLinkAny($item, $cachebuster);
}
}
/**
* Render a directory like a normal media link, but with open behaviour.
*
* @param array $item
* @return void
*/
protected function renderDirectoryLink($item)
{
$caption = $item['name'] ?? '';
$path = $item['path'] ?? '';
if ($caption === '') {
$caption = '[n/a]';
}
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
$this->renderer->cdata($caption);
return;
}
if (!is_string($path) || $path === '') {
$this->renderer->cdata('[n/a]');
return;
}
$path = $this->mapOpenPath($path);
global $conf;
/** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer;
$syntax = plugin_load('syntax', 'luxtools');
$serviceUrl = $syntax ? trim((string)$syntax->getConf('open_service_url')) : '';
// Prepare a DokuWiki-style link.
// Use the same icon mechanism as normal media links (via $link['class']).
$link = [
'target' => $conf['target']['extern'],
'style' => '',
'pre' => '',
'suf' => '',
'name' => $caption,
'url' => '#',
'title' => $renderer->_xmlEntities($path),
'more' => '',
];
$noIcon = !empty($item['noicon']);
$link['class'] = $noIcon
? 'luxtools-open'
: 'luxtools-open media mediafile mf_folder';
$link['more'] .= ' data-path="' . hsc($path) . '"';
if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"';
if ($serviceUrl !== '') $link['more'] .= ' data-service-url="' . hsc($serviceUrl) . '"';
$renderer->doc .= $renderer->_formatLink($link);
}
/**
* Map a filesystem path to an alias path (if configured).
*
* @param string $path
* @return string
*/
protected function mapOpenPath($path)
{
if ($this->openPathMapper === false) return $path;
if ($this->openPathMapper === null) {
$syntax = plugin_load('syntax', 'luxtools');
$pathConfig = $syntax ? (string)$syntax->getConf('paths') : '';
if (trim($pathConfig) === '') {
$this->openPathMapper = false;
return $path;
}
$this->openPathMapper = new Path($pathConfig);
}
return $this->openPathMapper->mapToAliasPath($path);
}
/**
* Render a file link on the XHTML renderer
*/
protected function renderItemLinkXHTML($item, $cachebuster = false)
{
global $conf;
/** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer;
//prepare for formating
$link['target'] = $conf['target']['extern'];
$link['style'] = '';
$link['pre'] = '';
$link['suf'] = '';
$link['more'] = '';
$link['url'] = $this->itemWebUrl($item, $cachebuster);
$link['name'] = $item['name'];
$link['title'] = $renderer->_xmlEntities($link['url']);
if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"';
[$ext,] = mimetype(basename($item['local']));
$link['class'] = 'media mediafile mf_' . $ext;
$renderer->doc .= $renderer->_formatLink($link);
}
/**
* Render a file link on any Renderer
* @param array $item
* @param bool $cachebuster
* @return void
*/
protected function renderItemLinkAny($item, $cachebuster = false)
{
$this->renderer->externalmedialink($this->itemWebUrl($item, $cachebuster), $item['name']);
}
/**
* Construct the Web URL for a given item
*
* @param array $item The item data as returned by the Crawler
* @param bool $cachbuster add a cachebuster to the URL?
* @return string
*/
protected function itemWebUrl($item, $cachbuster = false)
{
$webdir = $this->webdir;
// When using the built-in file-serving endpoint, include the current page id
// so file.php can enforce DokuWiki ACLs for that page.
if (
is_string($webdir)
&& $webdir !== ''
&& strpos($webdir, 'lib/plugins/luxtools/file.php') !== false
&& strpos($webdir, 'id=') === false
) {
global $ID;
$pageId = isset($ID) ? (string)$ID : '';
if ($pageId !== '') {
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($pageId !== '') {
$encoded = rawurlencode($pageId);
if (strpos($webdir, '&file=') !== false) {
$webdir = str_replace('&file=', '&id=' . $encoded . '&file=', $webdir);
} elseif (strpos($webdir, '?file=') !== false) {
$webdir = str_replace('?file=', '?id=' . $encoded . '&file=', $webdir);
}
}
}
}
if (str_ends_with($webdir, '=')) {
$url = $webdir . rawurlencode($item['local']);
} else {
$url = $webdir . $item['local'];
}
if ($cachbuster) {
if (strpos($url, '?') === false) {
$url .= '?t=' . $item['mtime'];
} else {
$url .= '&t=' . $item['mtime'];
}
}
return $url;
}
/**
* Flattens the filelist by recursively walking through all subtrees and
* merging them with a prefix attached to the filenames.
*
* @param array $items the tree to flatten
* @param string $prefix the prefix to attach to all processed nodes
* @return array a flattened representation of the tree
*/
protected function flattenResultTree($items, $prefix = '')
{
$result = [];
foreach ($items as $file) {
if ($file['children'] !== false) {
$result = array_merge(
$result,
$this->flattenResultTree($file['children'], $prefix . $file['name'] . '/')
);
} else {
$file['name'] = $prefix . $file['name'];
$result[] = $file;
}
}
return $result;
}
protected function getLang($key)
{
$syntax = plugin_load('syntax', 'luxtools');
return $syntax->getLang($key);
}
}

216
src/Path.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
namespace dokuwiki\plugin\luxtools;
class Path
{
protected $paths = [];
/**
* @param string $pathConfig The path configuration ftom the plugin settings
*/
public function __construct($pathConfig)
{
$this->paths = $this->parsePathConfig($pathConfig);
}
/**
* Access the parsed paths
*
* @return array
*/
public function getPaths()
{
return $this->paths;
}
/**
* Parse the path configuration into an internal array
*
* roots (and aliases) are always saved with a trailing slash
*
* @return array
*/
protected function parsePathConfig($pathConfig)
{
$paths = [];
$lines = explode("\n", $pathConfig);
$lastRoot = '';
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
if (str_starts_with($line, 'A>')) {
// this is an alias for the last read root
$line = trim(substr($line, 2));
if (!isset($paths[$lastRoot])) continue; // no last root, no alias
$alias = static::cleanPath($line);
$paths[$lastRoot]['alias'] = $alias;
$paths[$alias] = &$paths[$lastRoot]; // alias references the original
} else {
// this is a new path
$line = static::cleanPath($line);
$lastRoot = $line;
$paths[$line] = [
'root' => $line,
'web' => DOKU_BASE . 'lib/plugins/luxtools/file.php?root=' . rawurlencode($line) . '&file=',
];
}
}
return $paths;
}
/**
* Check if a given path is listable and return it's configuration
*
* @param string $path
* @param bool $addTrailingSlash
* @return array
* @throws \Exception if the given path is not allowed
*/
public function getPathInfo($path, $addTrailingSlash = true)
{
$path = static::cleanPath($path, $addTrailingSlash);
$paths = $this->paths;
if ($paths === []) {
throw new \Exception('No paths configured');
}
$allowed = array_keys($paths);
usort($allowed, static fn($a, $b) => strlen($a) - strlen($b));
$allowed = array_map('preg_quote_cb', $allowed);
$regex = '/^(' . implode('|', $allowed) . ')/';
if (!preg_match($regex, $path, $matches)) {
throw new \Exception('Path not allowed: ' . $path);
}
$match = $matches[1];
$pathInfo = $paths[$match];
$pathInfo['local'] = substr($path, strlen($match));
$pathInfo['path'] = $pathInfo['root'] . $pathInfo['local'];
return $pathInfo;
}
/**
* Map a real filesystem path back to a configured alias, if available.
*
* Example: root "/share/Datascape/" with alias "/Scape/" maps
* "/share/Datascape/some/folder" -> "/Scape/some/folder".
*
* If no alias matches, the input path is returned unchanged (except for
* normalization of slashes and dot-segments).
*
* @param string $path
* @return string
*/
public function mapToAliasPath($path)
{
if (!is_string($path) || $path === '') return $path;
// normalize input for matching, but do not force a trailing slash
$normalized = static::cleanPath($path, false);
// collect root->alias mappings (avoid alias keys that reference the same config)
$mappings = [];
foreach ($this->paths as $key => $info) {
if (!isset($info['root']) || $key !== $info['root']) continue;
if (empty($info['alias'])) continue;
$mappings[$info['root']] = $info['alias'];
}
if ($mappings === []) return $normalized;
// Prefer the longest matching root (handles nested/overlapping roots)
uksort($mappings, static fn($a, $b) => strlen($b) - strlen($a));
foreach ($mappings as $root => $alias) {
if (str_starts_with($normalized, $root)) {
$suffix = substr($normalized, strlen($root));
$alias = static::cleanPath($alias, true);
return rtrim($alias, '/') . '/' . $suffix;
}
}
return $normalized;
}
/**
* Clean a path for better comparison
*
* Converts all backslashes to forward slashes
* Keeps leading double backslashes for UNC paths
* Ensure a single trailing slash unless disabled
*
* @param string $path
* @return string
*/
public static function cleanPath($path, $addTrailingSlash = true)
{
if (str_starts_with($path, '\\\\')) {
$unc = '\\\\';
} else {
$unc = '';
}
$path = ltrim($path, '\\');
$path = str_replace('\\', '/', $path);
$path = self::realpath($path);
if ($addTrailingSlash) {
$path = rtrim($path, '/');
$path .= '/';
}
return $unc . $path;
}
/**
* Canonicalizes a given path. A bit like realpath, but without the resolving of symlinks.
*
* @author anonymous
* @see <http://www.php.net/manual/en/function.realpath.php#73563>
*/
public static function realpath($path)
{
$path = explode('/', $path);
$output = [];
$counter = count($path);
for ($i = 0; $i < $counter; $i++) {
if ('.' == $path[$i]) continue;
if ('' === $path[$i] && $i > 0) continue;
if ('..' == $path[$i] && '..' != ($output[count($output) - 1] ?? '')) {
array_pop($output);
continue;
}
$output[] = $path[$i];
}
return implode('/', $output);
}
/**
* Check if the given path is within the data or dokuwiki dir
*
* This whould prevent accidental or deliberate circumvention of the ACLs
*
* @param string $path and already cleaned path
* @return bool
*/
public static function isWikiControlled($path)
{
global $conf;
$dataPath = self::cleanPath($conf['savedir']);
if (str_starts_with($path, $dataPath)) {
return true;
}
$wikiDir = self::cleanPath(DOKU_INC);
if (str_starts_with($path, $wikiDir)) {
return true;
}
return false;
}
}

91
src/ScratchpadMap.php Normal file
View File

@@ -0,0 +1,91 @@
<?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;
}
// 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;
}
}