1<?php
2
3namespace dokuwiki\TreeBuilder;
4
5use dokuwiki\test\mock\Doku_Renderer;
6use dokuwiki\TreeBuilder\Node\AbstractNode;
7use dokuwiki\TreeBuilder\Node\ExternalLink;
8use dokuwiki\TreeBuilder\Node\Top;
9
10/**
11 * Abstract class to generate a tree
12 */
13abstract class AbstractBuilder
14{
15    protected bool $generated = false;
16
17    /** @var AbstractNode[] flat list of all nodes the generator found */
18    protected array $nodes = [];
19
20    /** @var Top top level element to access the tree */
21    protected Top $top;
22
23    /** @var callable|null A callback to modify or filter out nodes */
24    protected $nodeProcessor;
25
26    /** @var callable|null A callback to decide if recursion should happen */
27    protected $recursionDecision;
28
29    /**
30     * @var int configuration flags
31     */
32    protected int $flags = 0;
33
34    /**
35     * Generate the page tree. Needs to be called once the object is created.
36     *
37     * Sets the $generated flag to true.
38     *
39     * @return void
40     */
41    abstract public function generate(): void;
42
43    /**
44     * Set a callback to set additional properties on the nodes
45     *
46     * The callback receives a Node as parameter and must return a Node.
47     * If the callback returns null, the node will not be added to the tree.
48     * The callback may use the setProperty() method to set additional properties on the node.
49     * The callback can also return a completely different node, which will be added to the tree instead
50     * of the original node.
51     *
52     * @param callable|null $builder A callback to set additional properties on the nodes
53     */
54    public function setNodeProcessor(?callable $builder): void
55    {
56        if ($builder !== null && !is_callable($builder)) {
57            throw new \InvalidArgumentException('Property builder must be callable');
58        }
59        $this->nodeProcessor = $builder;
60    }
61
62    /**
63     * Set a callback to decide if recursion should happen
64     *
65     * The callback receives a Node as parameter and the current recursion depth.
66     * The node will NOT have it's children set.
67     * The callback must return true to have any children added, false to skip them.
68     *
69     * @param callable|null $filter
70     * @return void
71     */
72    public function setRecursionDecision(?callable $filter): void
73    {
74        if ($filter !== null && !is_callable($filter)) {
75            throw new \InvalidArgumentException('Recursion-filter must be callable');
76        }
77        $this->recursionDecision = $filter;
78    }
79
80    /**
81     * Add a configuration flag
82     *
83     * @param int $flag
84     * @return void
85     */
86    public function addFlag(int $flag): void
87    {
88        $this->flags |= $flag;
89    }
90
91    /**
92     * Check if a flag is set
93     *
94     * @param int $flag
95     * @return bool
96     */
97    public function hasFlag(int $flag): bool
98    {
99        return ($this->flags & $flag) === $flag;
100    }
101
102    /**
103     * Check if a flag is NOT set
104     *
105     * @param int $flag
106     * @return bool
107     */
108    public function hasNotFlag(int $flag): bool
109    {
110        return ($this->flags & $flag) !== $flag;
111    }
112
113    /**
114     * Remove a configuration flag
115     *
116     * @param int $flag
117     * @return void
118     */
119    public function removeFlag(int $flag): void
120    {
121        $this->flags &= ~$flag;
122    }
123
124    /**
125     * Access the top element
126     *
127     * Use it's children to iterate over the page hierarchy
128     *
129     * @return Top
130     */
131    public function getTop(): Top
132    {
133        if (!$this->generated) throw new \RuntimeException('need to call generate() first');
134        return $this->top;
135    }
136
137    /**
138     * Get a flat list of all nodes in the tree
139     *
140     * This is a cached version of top->getDescendants() with the ID as key of the returned array.
141     *
142     * @return AbstractNode[]
143     */
144    public function getAll(): array
145    {
146        if (!$this->generated) throw new \RuntimeException('need to call generate() first');
147        if ($this->nodes === []) {
148            $this->nodes = [];
149            foreach ($this->top->getDescendants() as $node) {
150                $this->nodes[$node->getId()] = $node;
151            }
152        }
153
154        return $this->nodes;
155    }
156
157    /**
158     * Get a flat list of all nodes that do NOT have children
159     *
160     * @return AbstractNode[]
161     */
162    public function getLeaves(): array
163    {
164        if (!$this->generated) throw new \RuntimeException('need to call generate() first');
165        return array_filter($this->getAll(), fn($page) => !$page->getChildren());
166    }
167
168    /**
169     * Get a flat list of all nodes that DO have children
170     *
171     * @return AbstractNode[]
172     */
173    public function getBranches(): array
174    {
175        if (!$this->generated) throw new \RuntimeException('need to call generate() first');
176        return array_filter($this->getAll(), fn($page) => (bool) $page->getChildren());
177    }
178
179    /**
180     * Sort the tree
181     *
182     * The given comparator function will be called with two nodes as arguments and needs to
183     * return an integer less than, equal to, or greater than zero if the first argument is considered
184     * to be respectively less than, equal to, or greater than the second.
185     *
186     * Pass in one of the TreeSort comparators or your own.
187     *
188     * @param callable $comparator
189     * @return void
190     */
191    public function sort(callable $comparator): void
192    {
193        if (!$this->generated) throw new \RuntimeException('need to call generate() first');
194        $this->top->sort($comparator);
195        $this->nodes = []; // reset the cache
196    }
197
198    /**
199     * Render the tree on the given renderer
200     *
201     * This is mostly an example implementation. You probably want to implement your own.
202     *
203     * @param Doku_Renderer $R The current renderer
204     * @param AbstractNode $top The node to start from, use null to start from the top node
205     * @param int $level current nesting level, starting at 1
206     * @return void
207     */
208    public function render(Doku_Renderer $R, $top = null, $level = 1): void
209    {
210        if ($top === null) $top = $this->getTop();
211
212        $R->listu_open();
213        foreach ($top->getChildren() as $node) {
214            $R->listitem_open(1, $node->hasChildren());
215            $R->listcontent_open();
216            if ($node instanceof ExternalLink) {
217                $R->externallink($node->getId(), $node->getTitle());
218            } else {
219                $R->internallink($node->getId(), $node->getTitle());
220            }
221            $R->listcontent_close();
222            if ($node->hasChildren()) {
223                $this->render($R, $node, $level + 1);
224            }
225            $R->listitem_close();
226        }
227        $R->listu_close();
228    }
229
230    /**
231     * @param AbstractNode $node
232     * @return AbstractNode|null
233     */
234    protected function applyNodeProcessor(AbstractNode $node): ?AbstractNode
235    {
236        if ($this->nodeProcessor === null) return $node;
237        $result = call_user_func($this->nodeProcessor, $node);
238        if (!$result instanceof AbstractNode) return null;
239        return $result;
240    }
241
242    /**
243     * @param AbstractNode $node
244     * @return bool should children be added?
245     */
246    protected function applyRecursionDecision(AbstractNode $node, int $depth): bool
247    {
248        if ($this->recursionDecision === null) return true;
249        return (bool)call_user_func($this->recursionDecision, $node, $depth);
250    }
251
252    /**
253     * "prints" the tree
254     *
255     * @return array
256     */
257    public function __toString(): string
258    {
259        return implode("\n", $this->getAll());
260    }
261}
262