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 string The top level namespace to iterate over */
43    protected string $namespace;
44
45    /** @var int The maximum depth to iterate into, -1 for infinite */
46    protected int $maxdepth;
47
48
49    /**
50     * Constructor
51     *
52     * @param string $namespace The namespace to start from
53     * @param int $maxdepth The maximum depth to iterate into, -1 for infinite
54     */
55    public function __construct(string $namespace, int $maxdepth = -1)
56    {
57        $this->namespace = $namespace;
58        $this->maxdepth = $maxdepth;
59    }
60
61    /** @inheritdoc */
62    public function generate(): void
63    {
64        $this->generated = true;
65
66        $this->top = new Top();
67
68        // add directly to top or add the namespace under the top element?
69        if ($this->hasFlag(self::FLAG_SELF_TOP)) {
70            $parent = $this->createNamespaceNode($this->namespace, noNS($this->namespace));
71            $parent->setParent($this->top);
72        } else {
73            if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
74                // do not add the namespace's own startpage in this mode
75                $this->startpages[$this->getStartpage($this->namespace)] = 1;
76            }
77
78            $parent = $this->top;
79        }
80
81        // if FLAG_SELF_TOP, we need to run a recursion decision on the parent
82        if ($parent instanceof Top || $this->applyRecursionDecision($parent, 0)) {
83            $dir = $this->namespacePath($this->namespace);
84            $this->createHierarchy($parent, $dir, $this->maxdepth);
85        }
86
87        // if FLAG_SELF_TOP, we need to add the parent to the top
88        if (!$parent instanceof Top) {
89            $this->addNodeToHierarchy($this->top, $parent);
90        }
91    }
92
93    /**
94     * Recursive function to create the page hierarchy
95     *
96     * @param AbstractNode $parent results are added as children to this element
97     * @param string $dir The directory relative to the page directory
98     * @param int $depth Current depth, recursion stops at 0
99     * @return void
100     */
101    protected function createHierarchy(AbstractNode $parent, string $dir, int $depth)
102    {
103        // Process namespaces (subdirectories)
104        if ($this->hasNotFlag(self::FLAG_NO_NS)) {
105            $this->processNamespaces($parent, $dir, $depth);
106        }
107
108        // Process pages (files)
109        if ($this->hasNotFlag(self::FLAG_NO_PAGES)) {
110            $this->processPages($parent, $dir);
111        }
112    }
113
114    /**
115     * Process namespaces (subdirectories) and add them to the hierarchy
116     *
117     * @param AbstractNode $parent Parent node to add children to
118     * @param string $dir Current directory path
119     * @param int $depth Current depth level
120     * @return void
121     */
122    protected function processNamespaces(AbstractNode $parent, string $dir, int $depth)
123    {
124        global $conf;
125        $base = $conf['datadir'] . '/';
126
127        $dirs = glob($base . $dir . '/*', GLOB_ONLYDIR);
128        foreach ($dirs as $subdir) {
129            $subdir = basename($subdir);
130            $id = pathID($dir . '/' . $subdir);
131
132            $node = $this->createNamespaceNode($id, $subdir);
133
134            // Recurse into subdirectory if depth and filter allows
135            if ($depth !== 0 && $this->applyRecursionDecision($node, $this->maxdepth - $depth)) {
136                $this->createHierarchy($node, $dir . '/' . $subdir, $depth - 1);
137            }
138
139            // Add to hierarchy
140            $this->addNodeToHierarchy($parent, $node);
141        }
142    }
143
144    /**
145     * Create a namespace node based on the flags
146     *
147     * @param string $id
148     * @param string $title
149     * @return AbstractNode
150     */
151    protected function createNamespaceNode(string $id, string $title): AbstractNode
152    {
153        if ($this->hasFlag(self::FLAG_NS_AS_STARTPAGE)) {
154            $ns = $id;
155            $id = $this->getStartpage($id); // use the start page for the namespace
156            $this->startpages[$id] = 1; // mark as seen
157            $node = new WikiStartpage($id, $title, $ns);
158        } else {
159            $node = new WikiNamespace($id, $title);
160        }
161        return $node;
162    }
163
164    /**
165     * Process pages (files) and add them to the hierarchy
166     *
167     * @param AbstractNode $parent Parent node to add children to
168     * @param string $dir Current directory path
169     * @return void
170     */
171    protected function processPages(AbstractNode $parent, string $dir)
172    {
173        global $conf;
174        $base = $conf['datadir'] . '/';
175
176        $files = glob($base . $dir . '/*.txt');
177        foreach ($files as $file) {
178            $file = basename($file);
179            $id = pathID($dir . '/' . $file);
180
181            // Skip already shown start pages
182            if (isset($this->startpages[$id])) {
183                continue;
184            }
185
186            $page = new WikiPage($id, $file);
187
188            // Add to hierarchy
189            $this->addNodeToHierarchy($parent, $page);
190        }
191    }
192
193    /**
194     * Run custom node processor and add it to the hierarchy
195     *
196     * @param AbstractNode $parent Parent node
197     * @param AbstractNode $node Node to add
198     * @return void
199     */
200    protected function addNodeToHierarchy(AbstractNode $parent, AbstractNode $node): void
201    {
202        $node->setParent($parent); // set the parent even when not added, yet
203        $node = $this->applyNodeProcessor($node);
204        if ($node instanceof AbstractNode) {
205            $parent->addChild($node);
206        }
207    }
208
209    /**
210     * Get the start page for the given namespace
211     *
212     * @param string $ns The namespace to get the start page for
213     * @return string The start page id
214     */
215    protected function getStartpage(string $ns): string
216    {
217        $id = $ns . ':';
218        return (new PageResolver(''))->resolveId($id);
219    }
220
221    /**
222     * Get the file path for the given namespace relative to the page directory
223     *
224     * @param string $namespace
225     * @return string
226     */
227    protected function namespacePath(string $namespace): string
228    {
229        global $conf;
230
231        $base = $conf['datadir'] . '/';
232        $dir = wikiFN($namespace . ':xxx');
233        $dir = substr($dir, strlen($base));
234        $dir = dirname($dir); // remove the 'xxx' part
235        if ($dir === '.') $dir = ''; // dirname returns '.' for root namespace
236        return $dir;
237    }
238
239    /** @inheritdoc */
240    protected function applyRecursionDecision(AbstractNode $node, int $depth): bool
241    {
242        // automatically skip hidden elements unless disabled by flag
243        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
244            return false;
245        }
246        return parent::applyRecursionDecision($node, $depth);
247    }
248
249    /** @inheritdoc */
250    protected function applyNodeProcessor(AbstractNode $node): ?AbstractNode
251    {
252        // automatically skip hidden elements unless disabled by flag
253        if (!$this->hasNotFlag(self::FLAG_KEEP_HIDDEN) && isHiddenPage($node->getId())) {
254            return null;
255        }
256        return parent::applyNodeProcessor($node);
257    }
258}
259