xref: /dokuwiki/inc/TreeBuilder/PageTreeBuilder.php (revision 31003314c05cb43e9840ac995129d131e4e750b8)
1<?php
2
3namespace dokuwiki\TreeBuilder;
4
5use dokuwiki\File\PageResolver;
6use dokuwiki\TreeBuilder\Node\AbstractNode;
7use dokuwiki\TreeBuilder\Node\Top;
8use dokuwiki\TreeBuilder\Node\WikiNamespace;
9use dokuwiki\TreeBuilder\Node\WikiPage;
10use dokuwiki\TreeBuilder\Node\WikiStartpage;
11use dokuwiki\Utf8\PhpString;
12
13/**
14 * A tree builder for wiki pages and namespaces
15 *
16 * This replace the classic search_* functions approach and provides a way to create a traversable tree
17 * of wiki pages and namespaces.
18 *
19 * The created hierarchy can either use WikiNamespace nodes or represent namespaces as WikiPage nodes
20 * associated with the namespace's start page.
21 */
22class PageTreeBuilder extends AbstractBuilder
23{
24    /** @var array Used to remember already seen start pages */
25    protected array $startpages = [];
26
27    /** @var int Return WikiPage(startpage) instead of WikiNamespace(id) for namespaces */
28    public const FLAG_NS_AS_STARTPAGE = 1;
29
30    /** @var int Do not return Namespaces, will also disable recursion */
31    public const FLAG_NO_NS = 2;
32
33    /** @var int Do not return pages */
34    public const FLAG_NO_PAGES = 4;
35
36    /** @var int Do not filter out hidden pages */
37    public const FLAG_KEEP_HIDDEN = 8;
38
39    /** @var int The given namespace should be added as top element */
40    public const FLAG_SELF_TOP = 16;
41
42    /** @var int Do not filter out pages/namespaces with invalid IDs */
43    public const FLAG_KEEP_INVALID = 32;
44
45    /** @var string The top level namespace to iterate over */
46    protected string $namespace;
47
48    /** @var int The maximum depth to iterate into, -1 for infinite */
49    protected int $maxdepth;
50
51
52    /**
53     * Constructor
54     *
55     * @param string $namespace The namespace to start from
56     * @param int $maxdepth The maximum depth to iterate into, -1 for infinite
57     */
58    public function __construct(string $namespace, int $maxdepth = -1)
59    {
60        $this->namespace = $namespace;
61        $this->maxdepth = $maxdepth;
62    }
63
64    /** @inheritdoc */
65    public function generate(): void
66    {
67        $this->generated = true;
68
69        $this->top = new Top();
70
71        // add directly to top or add the namespace under the top element?
72        if ($this->hasFlag(self::FLAG_SELF_TOP)) {
73            $parent = $this->createNamespaceNode($this->namespace, noNS($this->namespace));
74            $parent->setParent($this->top);
75        } else {
76            if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
77                // do not add the namespace's own startpage in this mode
78                $this->startpages[$this->getStartpage($this->namespace)] = 1;
79            }
80
81            $parent = $this->top;
82        }
83
84        // if FLAG_SELF_TOP, we need to run a recursion decision on the parent
85        if ($parent instanceof Top || $this->applyRecursionDecision($parent, 0)) {
86            $dir = $this->namespacePath($this->namespace);
87            $this->createHierarchy($parent, $dir, $this->maxdepth);
88        }
89
90        // if FLAG_SELF_TOP, we need to add the parent to the top
91        if (!$parent instanceof Top) {
92            $this->addNodeToHierarchy($this->top, $parent);
93        }
94    }
95
96    /**
97     * Recursive function to create the page hierarchy
98     *
99     * @param AbstractNode $parent results are added as children to this element
100     * @param string $dir The directory relative to the page directory
101     * @param int $depth Current depth, recursion stops at 0
102     * @return void
103     */
104    protected function createHierarchy(AbstractNode $parent, string $dir, int $depth)
105    {
106        // Process namespaces (subdirectories)
107        if ($this->hasNotFlag(self::FLAG_NO_NS)) {
108            $this->processNamespaces($parent, $dir, $depth);
109        }
110
111        // Process pages (files)
112        if ($this->hasNotFlag(self::FLAG_NO_PAGES)) {
113            $this->processPages($parent, $dir);
114        }
115    }
116
117    /**
118     * Process namespaces (subdirectories) and add them to the hierarchy
119     *
120     * @param AbstractNode $parent Parent node to add children to
121     * @param string $dir Current directory path
122     * @param int $depth Current depth level
123     * @return void
124     */
125    protected function processNamespaces(AbstractNode $parent, string $dir, int $depth)
126    {
127        global $conf;
128        $base = $conf['datadir'] . '/';
129
130        $dirs = glob($base . $dir . '/*', GLOB_ONLYDIR); // will always ignore hidden dirs
131        foreach ($dirs as $subdir) {
132            $subdir = basename($subdir);
133            $id = pathID($dir . '/' . $subdir);
134
135            // Skip namespaces with invalid IDs (_private)
136            if ($this->hasNotFlag(self::FLAG_KEEP_INVALID) && cleanID($id) !== $id) {
137                continue;
138            }
139
140            $node = $this->createNamespaceNode($id, $subdir);
141
142            // Recurse into subdirectory if depth and filter allows
143            if ($depth !== 0 && $this->applyRecursionDecision($node, $this->maxdepth - $depth)) {
144                $this->createHierarchy($node, $dir . '/' . $subdir, $depth - 1);
145            }
146
147            // Add to hierarchy
148            $this->addNodeToHierarchy($parent, $node);
149        }
150    }
151
152    /**
153     * Create a namespace node based on the flags
154     *
155     * @param string $id
156     * @param string $title
157     * @return AbstractNode
158     */
159    protected function createNamespaceNode(string $id, string $title): AbstractNode
160    {
161        if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
162            $ns = $id;
163            $id = $this->getStartpage($id); // use the start page for the namespace
164            $this->startpages[$id] = 1; // mark as seen
165            $node = new WikiStartpage($id, $title, $ns);
166        } else {
167            $node = new WikiNamespace($id, $title);
168        }
169        return $node;
170    }
171
172    /**
173     * Process pages (files) and add them to the hierarchy
174     *
175     * @param AbstractNode $parent Parent node to add children to
176     * @param string $dir Current directory path
177     * @return void
178     */
179    protected function processPages(AbstractNode $parent, string $dir)
180    {
181        global $conf;
182        $base = $conf['datadir'] . '/';
183
184        $files = glob($base . $dir . '/*.txt');
185        foreach ($files as $file) {
186            $file = basename($file);
187            $id = pathID($dir . '/' . $file);
188
189            // Skip already shown start pages
190            if (isset($this->startpages[$id])) {
191                continue;
192            }
193
194            // Skip pages with invalid IDs (e.g., __template.txt)
195            if ($this->hasNotFlag(self::FLAG_KEEP_INVALID) && cleanID($id) !== $id) {
196                continue;
197            }
198
199            $page = new WikiPage($id, $file);
200
201            // Add to hierarchy
202            $this->addNodeToHierarchy($parent, $page);
203        }
204    }
205
206    /**
207     * Run custom node processor and add it to the hierarchy
208     *
209     * @param AbstractNode $parent Parent node
210     * @param AbstractNode $node Node to add
211     * @return void
212     */
213    protected function addNodeToHierarchy(AbstractNode $parent, AbstractNode $node): void
214    {
215        $node->setParent($parent); // set the parent even when not added, yet
216        $node = $this->applyNodeProcessor($node);
217        if ($node instanceof AbstractNode) {
218            $parent->addChild($node);
219        }
220    }
221
222    /**
223     * Get the start page for the given namespace
224     *
225     * @param string $ns The namespace to get the start page for
226     * @return string The start page id
227     */
228    protected function getStartpage(string $ns): string
229    {
230        $id = $ns . ':';
231        return (new PageResolver(''))->resolveId($id);
232    }
233
234    /**
235     * Get the file path for the given namespace relative to the page directory
236     *
237     * @param string $namespace
238     * @return string
239     */
240    protected function namespacePath(string $namespace): string
241    {
242        global $conf;
243
244        $base = $conf['datadir'] . '/';
245        $dir = wikiFN($namespace . ':xxx');
246        $dir = substr($dir, strlen($base));
247        $dir = dirname($dir); // remove the 'xxx' part
248        if ($dir === '.') $dir = ''; // dirname returns '.' for root namespace
249        return $dir;
250    }
251
252    /** @inheritdoc */
253    protected function applyRecursionDecision(AbstractNode $node, int $depth): bool
254    {
255        // automatically skip hidden elements unless disabled by flag
256        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
257            return false;
258        }
259        return parent::applyRecursionDecision($node, $depth);
260    }
261
262    /** @inheritdoc */
263    protected function applyNodeProcessor(AbstractNode $node): ?AbstractNode
264    {
265        // automatically skip hidden elements unless disabled by flag
266        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
267            return null;
268        }
269        return parent::applyNodeProcessor($node);
270    }
271}
272