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