dokuwiki/inc/TreeBuilder/PageTreeBuilder.php

<?php

namespace dokuwiki\TreeBuilder;

use dokuwiki\File\PageResolver;
use dokuwiki\TreeBuilder\Node\AbstractNode;
use dokuwiki\TreeBuilder\Node\Top;
use dokuwiki\TreeBuilder\Node\WikiNamespace;
use dokuwiki\TreeBuilder\Node\WikiPage;
use dokuwiki\TreeBuilder\Node\WikiStartpage;
use dokuwiki\Utf8\PhpString;

/**
 * A tree builder for wiki pages and namespaces
 *
 * This replace the classic search_* functions approach and provides a way to create a traversable tree
 * of wiki pages and namespaces.
 *
 * The created hierarchy can either use WikiNamespace nodes or represent namespaces as WikiPage nodes
 * associated with the namespace's start page.
 */
class PageTreeBuilder extends AbstractBuilder
{
    /** @var array Used to remember already seen start pages */
    protected array $startpages = [];

    /** @var int Return WikiPage(startpage) instead of WikiNamespace(id) for namespaces */
    public const FLAG_NS_AS_STARTPAGE = 1;

    /** @var int Do not return Namespaces, will also disable recursion */
    public const FLAG_NO_NS = 2;

    /** @var int Do not return pages */
    public const FLAG_NO_PAGES = 4;

    /** @var int Do not filter out hidden pages */
    public const FLAG_KEEP_HIDDEN = 8;

    /** @var int The given namespace should be added as top element */
    public const FLAG_SELF_TOP = 16;

    /** @var string The top level namespace to iterate over */
    protected string $namespace;

    /** @var int The maximum depth to iterate into, -1 for infinite */
    protected int $maxdepth;


    /**
     * Constructor
     *
     * @param string $namespace The namespace to start from
     * @param int $maxdepth The maximum depth to iterate into, -1 for infinite
     */
    public function __construct(string $namespace, int $maxdepth = -1)
    {
        $this->namespace = $namespace;
        $this->maxdepth = $maxdepth;
    }

    /** @inheritdoc */
    public function generate(): void
    {
        $this->generated = true;

        $this->top = new Top();

        // add directly to top or add the namespace under the top element?
        if ($this->hasFlag(self::FLAG_SELF_TOP)) {
            $parent = $this->createNamespaceNode($this->namespace, noNS($this->namespace));
            $parent->setParent($this->top);
        } else {
            if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
                // do not add the namespace's own startpage in this mode
                $this->startpages[$this->getStartpage($this->namespace)] = 1;
            }

            $parent = $this->top;
        }

        // if FLAG_SELF_TOP, we need to run a recursion decision on the parent
        if ($parent instanceof Top || $this->applyRecursionDecision($parent, 0)) {
            $dir = $this->namespacePath($this->namespace);
            $this->createHierarchy($parent, $dir, $this->maxdepth);
        }

        // if FLAG_SELF_TOP, we need to add the parent to the top
        if (!$parent instanceof Top) {
            $this->addNodeToHierarchy($this->top, $parent);
        }
    }

    /**
     * Recursive function to create the page hierarchy
     *
     * @param AbstractNode $parent results are added as children to this element
     * @param string $dir The directory relative to the page directory
     * @param int $depth Current depth, recursion stops at 0
     * @return void
     */
    protected function createHierarchy(AbstractNode $parent, string $dir, int $depth)
    {
        // Process namespaces (subdirectories)
        if ($this->hasNotFlag(self::FLAG_NO_NS)) {
            $this->processNamespaces($parent, $dir, $depth);
        }

        // Process pages (files)
        if ($this->hasNotFlag(self::FLAG_NO_PAGES)) {
            $this->processPages($parent, $dir);
        }
    }

    /**
     * Process namespaces (subdirectories) and add them to the hierarchy
     *
     * @param AbstractNode $parent Parent node to add children to
     * @param string $dir Current directory path
     * @param int $depth Current depth level
     * @return void
     */
    protected function processNamespaces(AbstractNode $parent, string $dir, int $depth)
    {
        global $conf;
        $base = $conf['datadir'] . '/';

        $dirs = glob($base . $dir . '/*', GLOB_ONLYDIR);
        foreach ($dirs as $subdir) {
            $subdir = basename($subdir);
            $id = pathID($dir . '/' . $subdir);

            $node = $this->createNamespaceNode($id, $subdir);

            // Recurse into subdirectory if depth and filter allows
            if ($depth !== 0 && $this->applyRecursionDecision($node, $this->maxdepth - $depth)) {
                $this->createHierarchy($node, $dir . '/' . $subdir, $depth - 1);
            }

            // Add to hierarchy
            $this->addNodeToHierarchy($parent, $node);
        }
    }

    /**
     * Create a namespace node based on the flags
     *
     * @param string $id
     * @param string $title
     * @return AbstractNode
     */
    protected function createNamespaceNode(string $id, string $title): AbstractNode
    {
        if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
            $ns = $id;
            $id = $this->getStartpage($id); // use the start page for the namespace
            $this->startpages[$id] = 1; // mark as seen
            $node = new WikiStartpage($id, $title, $ns);
        } else {
            $node = new WikiNamespace($id, $title);
        }
        return $node;
    }

    /**
     * Process pages (files) and add them to the hierarchy
     *
     * @param AbstractNode $parent Parent node to add children to
     * @param string $dir Current directory path
     * @return void
     */
    protected function processPages(AbstractNode $parent, string $dir)
    {
        global $conf;
        $base = $conf['datadir'] . '/';

        $files = glob($base . $dir . '/*.txt');
        foreach ($files as $file) {
            $file = basename($file);
            $id = pathID($dir . '/' . $file);

            // Skip already shown start pages
            if (isset($this->startpages[$id])) {
                continue;
            }

            $page = new WikiPage($id, $file);

            // Add to hierarchy
            $this->addNodeToHierarchy($parent, $page);
        }
    }

    /**
     * Run custom node processor and add it to the hierarchy
     *
     * @param AbstractNode $parent Parent node
     * @param AbstractNode $node Node to add
     * @return void
     */
    protected function addNodeToHierarchy(AbstractNode $parent, AbstractNode $node): void
    {
        $node->setParent($parent); // set the parent even when not added, yet
        $node = $this->applyNodeProcessor($node);
        if ($node instanceof AbstractNode) {
            $parent->addChild($node);
        }
    }

    /**
     * Get the start page for the given namespace
     *
     * @param string $ns The namespace to get the start page for
     * @return string The start page id
     */
    protected function getStartpage(string $ns): string
    {
        $id = $ns . ':';
        return (new PageResolver(''))->resolveId($id);
    }

    /**
     * Get the file path for the given namespace relative to the page directory
     *
     * @param string $namespace
     * @return string
     */
    protected function namespacePath(string $namespace): string
    {
        global $conf;

        $base = $conf['datadir'] . '/';
        $dir = wikiFN($namespace . ':xxx');
        $dir = substr($dir, strlen($base));
        $dir = dirname($dir); // remove the 'xxx' part
        if ($dir === '.') $dir = ''; // dirname returns '.' for root namespace
        return $dir;
    }

    /** @inheritdoc */
    protected function applyRecursionDecision(AbstractNode $node, int $depth): bool
    {
        // automatically skip hidden elements unless disabled by flag
        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
            return false;
        }
        return parent::applyRecursionDecision($node, $depth);
    }

    /** @inheritdoc */
    protected function applyNodeProcessor(AbstractNode $node): ?AbstractNode
    {
        // automatically skip hidden elements unless disabled by flag
        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
            return null;
        }
        return parent::applyNodeProcessor($node);
    }
}