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