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