From 0ad43bcf9c7f9b34cb0b44c3d0f94f52ccb97bcf Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 6 Jan 2026 08:56:42 +0100 Subject: [PATCH] Add directory listing syntax --- Crawler.php | 71 ++++++++++++++++++++++++ Output.php | 84 +++++++++++++++++++++++++++- _test/SyntaxTest.php | 30 ++++++++++ syntax/directory.php | 127 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 syntax/directory.php diff --git a/Crawler.php b/Crawler.php index aaecc9b..9216dac 100644 --- a/Crawler.php +++ b/Crawler.php @@ -122,6 +122,77 @@ class Crawler 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 * diff --git a/Output.php b/Output.php index d9c9678..4545822 100644 --- a/Output.php +++ b/Output.php @@ -91,6 +91,25 @@ class Output } } + /** + * Render a flat list (files and/or directories) as a table. + * + * @param array $params + * @return void + */ + public function renderAsFlatTable($params) + { + if ($this->renderer instanceof \Doku_Renderer_xhtml) { + $this->renderer->doc .= '
'; + } + + $this->renderTableItems($this->files, $params); + + if ($this->renderer instanceof \Doku_Renderer_xhtml) { + $this->renderer->doc .= '
'; + } + } + /** * Renders the files as a table, including details if configured that way. @@ -147,7 +166,11 @@ class Output if ($params['showsize']) { $renderer->tablecell_open(1, 'right'); - $renderer->cdata(filesize_h($item['size'])); + if (!empty($item['isdir'])) { + $renderer->cdata(''); + } else { + $renderer->cdata(filesize_h($item['size'])); + } $renderer->tablecell_close(); } @@ -217,6 +240,11 @@ class Output 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 { @@ -224,6 +252,60 @@ class Output } } + /** + * 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; + } + + global $conf; + /** @var \Doku_Renderer_xhtml $renderer */ + $renderer = $this->renderer; + + $syntax = plugin_load('syntax', 'luxtools'); + $serviceUrl = $syntax ? trim((string)$syntax->getConf('open_service_url')) : ''; + $serviceToken = $syntax ? trim((string)$syntax->getConf('open_service_token')) : ''; + + // Prepare a DokuWiki-style media link with a folder icon class. + $link = [ + 'target' => $conf['target']['extern'], + 'style' => '', + 'pre' => '', + 'suf' => '', + 'name' => $caption, + 'url' => '#', + 'title' => $renderer->_xmlEntities($path), + 'more' => ' class="filetools-open media mediafile mf_folder" data-path="' . hsc($path) . '"', + ]; + if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"'; + if ($serviceUrl !== '') { + $link['more'] .= ' data-service-url="' . hsc($serviceUrl) . '"'; + } + if ($serviceToken !== '') { + $link['more'] .= ' data-service-token="' . hsc($serviceToken) . '"'; + } + $renderer->doc .= $renderer->_formatLink($link); + } + /** * Render a file link on the XHTML renderer */ diff --git a/_test/SyntaxTest.php b/_test/SyntaxTest.php index f694014..b5893c0 100644 --- a/_test/SyntaxTest.php +++ b/_test/SyntaxTest.php @@ -233,4 +233,34 @@ class plugin_luxtools_test extends DokuWikiTest $this->assertStringContainsString('Open here', $xhtml); $this->assertStringContainsString('data-path="/tmp/somewhere"', $xhtml); } + + /** + * This function checks that the directory syntax renders a flat table, + * listing both folders and files. + */ + public function test_directory_table_flat() + { + $instructions = p_get_instructions('{{directory>' . TMP_DIR . '/filelistdata/&direct=1}}'); + $xhtml = p_render('xhtml', $instructions, $info); + + $doc = new Document(); + $doc->html($xhtml); + + $structure = [ + 'div.filetools-plugin' => 1, + 'div.filetools-plugin table' => 1, + 'div.filetools-plugin table > tbody > tr' => 3, + 'a.filetools-open' => 1, + ]; + $this->structureCheck($doc, $structure); + + // Should list the top-level entries, but not recurse into exampledir + $this->assertStringContainsString('example.txt', $xhtml); + $this->assertStringContainsString('exampleimage.png', $xhtml); + $this->assertStringContainsString('exampledir', $xhtml); + $this->assertStringNotContainsString('example2.txt', $xhtml); + + // Directory row should trigger the same behaviour as {{open>...}} for that folder + $this->assertStringContainsString('data-path="' . TMP_DIR . '/filelistdata/exampledir"', $xhtml); + } } diff --git a/syntax/directory.php b/syntax/directory.php new file mode 100644 index 0000000..84a568b --- /dev/null +++ b/syntax/directory.php @@ -0,0 +1,127 @@ +Lexer->addSpecialPattern('\{\{directory>.+?\}\}', $mode, 'plugin_luxtools_directory'); + } + + /** @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('{{directory>'), -2); + [$path, $flags] = explode('&', $match, 2); + + // load default config options + $flags = $this->getConf('defaults') . '&' . $flags; + $flags = explode('&', $flags); + + $params = [ + 'sort' => 'name', + 'order' => 'asc', + 'style' => 'list', + 'tableheader' => 0, + 'recursive' => 0, + 'titlefile' => '_title.txt', + 'cache' => 0, + 'randlinks' => 0, + 'showsize' => 0, + 'showdate' => 0, + 'listsep' => ', ', + ]; + + foreach ($flags as $flag) { + [$name, $value] = sexplode('=', $flag, 2, ''); + $params[trim($name)] = trim(trim($value), '"'); // quotes can be used to keep whitespace + } + + // directory path (no glob/pattern) + $path = Path::cleanPath($path, true); + + return [$path, $params]; + } + + /** @inheritdoc */ + public function render($format, Doku_Renderer $renderer, $data) + { + if ($data === false) return false; + [$path, $params] = $data; + + if ($format != 'xhtml' && $format != 'odt') { + return false; + } + + // disable caching + if ($params['cache'] === 0) { + $renderer->nocache(); + } + + try { + $pathHelper = new Path($this->getConf('paths')); + $pathInfo = $pathHelper->getPathInfo($path); + } catch (Exception $e) { + $renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']'); + return true; + } + + $crawler = new Crawler($this->getConf('extensions')); + $crawler->setSortBy($params['sort']); + $crawler->setSortReverse($params['order'] === 'desc'); + + $items = $crawler->listDirectory( + $pathInfo['root'], + $pathInfo['local'], + $params['titlefile'] + ); + + if ($items == []) { + $renderer->cdata('[n/a: ' . $this->getLang('error_nomatch') . ']'); + return true; + } + + // Always render as table style + $params['style'] = 'table'; + + $output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items); + $output->renderAsFlatTable($params); + return true; + } +}