1<?php
2
3namespace dokuwiki\plugin\indexmenu;
4
5use dokuwiki\Utf8\Sort;
6
7class Search
8{
9    /**
10     * @var bool|string sort by t=title, d=date of creation, 0 if not set i.e. default page sort (old dTree..)
11     */
12    private $sort;
13    /**
14     * @var string 'indexmenu_n' or other key from the metadata structure
15     */
16    private $msort;
17    /**
18     * @var bool Reverse the sorting of pages, combined with $nsort also the namespaces
19     */
20    private $rsort;
21    /**
22     * @var bool also sorts the namespaces
23     */
24    private $nsort;
25    /**
26     * @var bool Sort the headpages as defined by global config setting startpage to the top
27     */
28    private $hsort;
29
30    /**
31     * Search constructor.
32     *
33     * @param array $sort
34     *   $sort['sort']
35     *   $sort['msort']
36     *   $sort['rsort']
37     *   $sort['nsort']
38     *   $sort['hsort'];
39     */
40    public function __construct($sort)
41    {
42        $this->sort = $sort['sort'];
43        $this->msort = $sort['msort'];
44        $this->rsort = $sort['rsort'];
45        $this->nsort = $sort['nsort'];
46        $this->hsort = $sort['hsort'];
47    }
48
49    /**
50     * Build the data array for fancytree from search results
51     *
52     * @param array $data results from search
53     * @param bool $isInit true if first level of nodes from tree, false if next levels
54     * @param bool $currentPage current wikipage id
55     * @param bool $isNopg if nopg is set
56     * @return array
57     */
58    public function buildFancytreeData($data, $isInit, $currentPage, $isNopg)
59    {
60        if (empty($data)) return [];
61
62        $children = [];
63        $opts = [
64            'currentPage' => $currentPage,
65            'isParentLazy' => false,
66            'nopg' => $isNopg
67        ];
68        $hasActiveNode = false;
69        $this->makeNodes($data, -1, 0, $children, $hasActiveNode, $opts);
70
71        if ($isInit) {
72            $nodes['children'] = $children;
73            return $nodes;
74        } else {
75            return $children;
76        }
77    }
78
79    /**
80     * Collects the children at the same level since last parsed item
81     *
82     * @param array $data results from search
83     * @param int $indexLatestParsedItem
84     * @param int $previousLevel level of parent
85     * @param array $nodes by reference, here the child nodes are stored
86     * @param bool $hasActiveNode active node must be unique, needs tracking
87     * @param array $opts <ul>
88     *      <li>$opts['currentPage'] string id of main article</li>
89     *      <li>$opts['isParentLazy'] bool Used for recognizing the extra level below lazy nodes</li>
90     *      <li>$opts['nopg'] bool needed for currentpage handling</li>
91     * </ul>
92     * @return int latest parsed item from data array
93     */
94    private function makeNodes(&$data, $indexLatestParsedItem, $previousLevel, &$nodes, &$hasActiveNode, $opts)
95    {
96        $i = 0;
97        $counter = 0;
98        foreach ($data as $i => $item) {
99            //skip parsed items
100            if ($i <= $indexLatestParsedItem) {
101                continue;
102            }
103
104            if ($item['level'] < $previousLevel || $counter === 0 && $item['level'] == $previousLevel) {
105                return $i - 1;
106            }
107            $node = [
108                'title' => $item['title'],
109                'key' => $item['id'] . ($item['type'] === 'f' ? '' : ':'), //ensure ns is unique
110                'hns' => $item['hns'] //false if not available
111            ];
112
113            // f=file, d=directory, l=directory which is lazy loaded later
114            if ($item['type'] == 'f') {
115                // let php create url (considering rewriting etc)
116                $node['url'] = wl($item['id']);
117
118                //set current page to active
119                if ($opts['currentPage'] == $item['id']) {
120                    if (!$hasActiveNode) {
121                        $node['active'] = true;
122                        $hasActiveNode = true;
123                    }
124                }
125            } else {
126                // type: d/l
127                $node['folder'] = true;
128                // let php create url (considering rewriting etc)
129                $node['url'] = $item['hns'] === false ? false : wl($item['hns']);
130                if (!$item['hnsExists']) {
131                    //change link color
132                    $node['hnsNotExisting'] = true;
133                }
134
135                if ($item['open'] === true) {
136                    $node['expanded'] = true;
137                }
138
139                $node['children'] = [];
140                $indexLatestParsedItem = $this->makeNodes(
141                    $data,
142                    $i,
143                    $item['level'],
144                    $node['children'],
145                    $hasActiveNode,
146                    [
147                        'currentPage' => $opts['currentPage'],
148                        'isParentLazy' => $item['type'] === 'l',
149                        'nopg' => $opts['nopg']
150                    ]
151                );
152
153                // a lazy node, but because we have sometime no pages or nodes (due e.g. acl/hidden/nopg), it could be
154                // empty. Therefore we did extra work by walking a level deeper and check here whether it has children
155                if ($item['type'] === 'l') {
156                    if (empty($node['children'])) {
157                        //an empty lazy node, is not marked lazy
158                        if ($opts['isParentLazy']) {
159                            //a lazy node with a lazy parent has no children loaded, so stays always empty
160                            //(these nodes are not really used, but only counted)
161                            $node['lazy'] = true;
162                            unset($node['children']);
163                        }
164                    } else {
165                        //has children, so mark lazy
166                        $node['lazy'] = true;
167                        unset($node['children']); //do not keep, because these nodes do not know yet their child folders
168                    }
169                }
170
171                //might be duplicated if hide_headpage is disabled, or with nopg and a :same: headpage
172                //mark active after processing children, such that deepest level is activated
173                if (
174                    $item['hns'] === $opts['currentPage']
175                    || $opts['nopg'] && getNS($opts['currentPage']) === $item['id']
176                ) {
177                    //with hide_headpage enabled, the parent node must be actived
178                    //special: nopg has no pages, therefore, mark its parent node active
179                    if (!$hasActiveNode) {
180                        $node['active'] = true;
181                        $hasActiveNode = true;
182                    }
183                }
184            }
185
186            if ($item['type'] === 'f' || !empty($node['children']) || isset($node['lazy']) || $item['hns'] !== false) {
187                // add only files, non-empty folders, lazy-loaded or folder with only a headpage
188                $nodes[] = $node;
189            }
190
191            $previousLevel = $item['level'];
192            $counter++;
193        }
194        return $i;
195    }
196
197
198    /**
199     * Search pages/folders depending on the given options $opts
200     *
201     * @param string $ns
202     * @param array $opts<ul>
203     *  <li>$opts['skipns'] string regexp matching namespaceids to skip (ignored)</li>
204     *  <li>$opts['skipfile']  string regexp matching pageids to skip (ignored)</li>
205     *  <li>$opts['skipnscombined'] array regexp matching namespaceids to skip</li>
206     *  <li>$opts['skipfilecombined']  array regexp matching pageids to skip</li>
207     *  <li>$opts['headpage']   string headpages options or pageids</li>
208     *  <li>$opts['level']      int    desired depth of main namespace, -1 = all levels</li>
209     *  <li>$opts['subnss']     array with entries: array(namespaceid,level) specifying namespaces with their own
210     *                          number of opened levels</li>
211     *  <li>$opts['nons']       bool   exclude namespace nodes</li>
212     *  <li>$opts['max']        int    If initially closed, the node at max level will retrieve all its child nodes
213     *                          through the AJAX mechanism</li>
214     *  <li>$opts['nopg']       bool   exclude page nodes</li>
215     *  <li>$opts['hide_headpage'] int don't hide (0) or hide (1)</li>
216     *  <li>$opts['js']         bool   use js-render (only used for old 'searchIndexmenuItems')</li>
217     * </ul>
218     * @return array The results of the search
219     */
220    public function search($ns, $opts): array
221    {
222        global $conf;
223
224        if (!empty($opts['tempNew'])) {
225            //a specific callback for Fancytree
226            $callback = [$this, 'searchIndexmenuItemsNew'];
227        } else {
228            $callback = [$this, 'searchIndexmenuItems'];
229        }
230        $dataDir = $conf['datadir'];
231        $data = [];
232        $fsDir = "/" . utf8_encodeFN(str_replace(':', '/', $ns));
233        if ($this->sort || $this->msort || $this->rsort || $this->hsort) {
234            $this->customSearch($data, $dataDir, $callback, $opts, $fsDir);
235        } else {
236            search($data, $dataDir, $callback, $opts, $fsDir);
237        }
238        return $data;
239    }
240
241    /**
242     * Callback that adds an item of namespace/page to the browsable index, if it fits in the specified options
243     *
244     * @param array $data Already collected nodes
245     * @param string $base Where to start the search, usually this is $conf['datadir']
246     * @param string $file Current file or directory relative to $base
247     * @param string $type Type either 'd' for directory or 'f' for file
248     * @param int $lvl Current recursion depth
249     * @param array $opts Option array as given to search():<ul>
250     *   <li>$opts['skipns'] string regexp matching namespaceids to skip (ignored),</li>
251     *   <li>$opts['skipfile'] string regexp matching pageids to skip (ignored),</li>
252     *   <li>$opts['skipnscombined'] array regexp matching namespaceids to skip,</li>
253     *   <li>$opts['skipfilecombined'] array regexp matching pageids to skip,</li>
254     *   <li>$opts['headpage'] string headpages options or pageids,</li>
255     *   <li>$opts['level'] int desired depth of main namespace, -1 = all levels,</li>
256     *   <li>$opts['subnss'] array with entries: array(namespaceid,level) specifying namespaces with their own number
257     *                       of opened levels,</li>
258     *   <li>$opts['nons'] bool Exclude namespace nodes,</li>
259     *   <li>$opts['max'] int If initially closed, the node at max level will retrieve all its child nodes through
260     *                    the AJAX mechanism,</li>
261     *   <li>$opts['nopg'] bool Exclude page nodes,</li>
262     *   <li>$opts['hide_headpage'] int don't hide (0) or hide (1),</li>
263     *   <li>$opts['js'] bool use js-render</li>
264     * </ul>
265     * @return bool if this directory should be traversed (true) or not (false)
266     *
267     * @author  Andreas Gohr <andi@splitbrain.org>
268     * modified by Samuele Tognini <samuele@samuele.netsons.org>
269     */
270    public function searchIndexmenuItems(&$data, $base, $file, $type, $lvl, $opts)
271    {
272        global $conf;
273
274        $hns = false;
275        $isOpen = false;
276        $title = null;
277        $skipns = $opts['skipnscombined'];
278        $skipfile = $opts['skipfilecombined'];
279        $headpage = $opts['headpage'];
280        $id = pathID($file);
281
282        if ($type == 'd') {
283            // Skip folders in plugin conf
284            foreach ($skipns as $skipn) {
285                if (!empty($skipn) && preg_match($skipn, $id)) {
286                    return false;
287                }
288            }
289            //check ACL (for sneaky_index namespaces too).
290            if ($conf['sneaky_index'] && auth_quickaclcheck($id . ':') < AUTH_READ) return false;
291
292            //Open requested level
293            if ($opts['level'] > $lvl || $opts['level'] == -1) {
294                $isOpen = true;
295            }
296            //Search optional subnamespaces with
297            if (!empty($opts['subnss'])) {
298                $subnss = $opts['subnss'];
299                $counter = count($subnss);
300                for ($a = 0; $a < $counter; $a++) {
301                    if (preg_match("/^" . $id . "($|:.+)/i", $subnss[$a][0], $match)) {
302                        //It contains a subnamespace
303                        $isOpen = true;
304                    } elseif (preg_match("/^" . $subnss[$a][0] . "(:.*)/i", $id, $match)) {
305                        //It's inside a subnamespace, check level
306                        // -1 is open all, otherwise count number of levels in the remainer of the pageid
307                        // (match[0] is always prefixed with :)
308                        if ($subnss[$a][1] == -1 || substr_count($match[1], ":") < $subnss[$a][1]) {
309                            $isOpen = true;
310                        } else {
311                            $isOpen = false;
312                        }
313                    }
314                }
315            }
316
317            //decide if it should be traversed
318            if ($opts['nons']) {
319                return $isOpen; // in nons, level is only way to show/hide nodes (in nons nodes are not expandable)
320            } elseif ($opts['max'] > 0 && !$isOpen && $lvl >= $opts['max']) {
321                //Stop recursive searching
322                $shouldBeTraversed = false;
323                //change type
324                $type = "l";
325            } elseif ($opts['js']) {
326                $shouldBeTraversed = true; //TODO if js tree, then traverse deeper???
327            } else {
328                $shouldBeTraversed = $isOpen;
329            }
330            //Set title and headpage
331            $title = static::getNamespaceTitle($id, $headpage, $hns);
332            // when excluding page nodes: guess a headpage based on the headpage setting
333            if ($opts['nopg'] && $hns === false) {
334                $hns = $this->guessHeadpage($headpage, $id);
335            }
336        } else {
337            //Nopg. Dont show pages
338            if ($opts['nopg']) return false;
339
340            $shouldBeTraversed = true;
341            //Nons.Set all pages at first level
342            if ($opts['nons']) {
343                $lvl = 1;
344            }
345            //don't add
346            if (substr($file, -4) != '.txt') return false;
347            //check hiddens and acl
348            if (isHiddenPage($id) || auth_quickaclcheck($id) < AUTH_READ) return false;
349            //Skip files in plugin conf
350            foreach ($skipfile as $skipf) {
351                if (!empty($skipf) && preg_match($skipf, $id)) {
352                    return false;
353                }
354            }
355            //Skip headpages to hide (nons has no namespace nodes, therefore, no duplicated links to headpage)
356            if (!$opts['nons'] && !empty($headpage) && $opts['hide_headpage']) {
357                //start page is in root
358                if ($id == $conf['start']) return false;
359
360                $ahp = explode(",", $headpage);
361                foreach ($ahp as $hp) {
362                    switch ($hp) {
363                        case ":inside:":
364                            if (noNS($id) == noNS(getNS($id))) return false;
365                            break;
366                        case ":same:":
367                            if (@is_dir(dirname(wikiFN($id)) . "/" . utf8_encodeFN(noNS($id)))) return false;
368                            break;
369                        //it' s an inside start
370                        case ":start:":
371                            if (noNS($id) == $conf['start']) return false;
372                            break;
373                        default:
374                            if (noNS($id) == cleanID($hp)) return false;
375                    }
376                }
377            }
378            //Set title
379            if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') {
380                $title = p_get_first_heading($id, false);
381            }
382            if (is_null($title)) {
383                $title = noNS($id);
384            }
385            $title = hsc($title);
386        }
387
388        $item = [
389            'id' => $id,
390            'type' => $type,
391            'level' => $lvl,
392            'open' => $isOpen,
393            'title' => $title,
394            'hns' => $hns,
395            'file' => $file,
396            'shouldBeTraversed' => $shouldBeTraversed
397        ];
398        $item['sort'] = $this->getSortValue($item);
399        $data[] = $item;
400
401        return $shouldBeTraversed;
402    }
403
404    /**
405     * Callback that adds an item of namespace/page to the browsable index, if it fits in the specified options
406     *
407     * TODO Version as used for Fancytree js tree
408     *
409     * @param array $data indexed array of collected nodes, each item has:<ul>
410     *   <li>$item['id'] string namespace or page id</li>
411     *   <li>$item['type'] string f/d/l</li>
412     *   <li>$item['level'] string current recursion depth (start count at 1)</li>
413     *   <li>$item['open'] bool if a node is open</li>
414     *   <li>$item['title'] string </li>
415     *   <li>$item['hns'] string|false page id or false</li>
416     *   <li>$item['hnsExists'] bool only false if hns is guessed(not-existing) for nopg</li>
417     *   <li>$item['file'] string path to file or directory</li>
418     *   <li>$item['shouldBeTraversed'] bool directory should be searched</li>
419     *   <li>$item['sort'] mixed sort value</li>
420     * </ul>
421     * @param string $base Where to start the search, usually this is $conf['datadir']
422     * @param string $file Current file or directory relative to $base
423     * @param string $type Type either 'd' for directory or 'f' for file
424     * @param int $lvl Current recursion depth
425     * @param array $opts Option array as given to search()<ul>
426     *   <li>$opts['skipns'] string regexp matching namespaceids to skip (ignored)</li>
427     *   <li>$opts['skipfile']  string regexp matching pageids to skip (ignored)</li>
428     *   <li>$opts['skipnscombined'] array regexp matching namespaceids to skip</li>
429     *   <li>$opts['skipfilecombined'] array regexp matching pageids to skip</li>
430     *   <li>$opts['headpage']   string headpages options or pageids</li>
431     *   <li>$opts['level']      int    desired depth of main namespace, -1 = all levels</li>
432     *   <li>$opts['subnss']     array with entries: array(namespaceid,level) specifying namespaces with their
433     *                           own level</li>
434     *   <li>$opts['nons']       bool   exclude namespace nodes</li>
435     *   <li>$opts['max']        int    If initially closed, the node at max level will retrieve all its child nodes
436     *                              through the AJAX mechanism</li>
437     *   <li>$opts['nopg']       bool   exclude page nodes</li>
438     *   <li>$opts['hide_headpage'] int don't hide (0) or hide (1)</li>
439     * </ul>
440     * @return bool if this directory should be traversed (true) or not (false)
441     *
442     * @author  Andreas Gohr <andi@splitbrain.org>
443     * modified by Samuele Tognini <samuele@samuele.netsons.org>
444     */
445    public function searchIndexmenuItemsNew(&$data, $base, $file, $type, $lvl, $opts)
446    {
447        global $conf;
448
449        $hns = false;
450        $isOpen = false;
451        $title = null;
452        $skipns = $opts['skipnscombined'];
453        $skipfile = $opts['skipfilecombined'];
454        $headpage = $opts['headpage'];
455        $hnsExists = true; //nopg guesses pages
456        $id = pathID($file);
457
458        if ($type == 'd') {
459            // Skip folders in plugin conf
460            foreach ($skipns as $skipn) {
461                if (!empty($skipn) && preg_match($skipn, $id)) {
462                    return false;
463                }
464            }
465            //check ACL (for sneaky_index namespaces too).
466            if ($conf['sneaky_index'] && auth_quickaclcheck($id . ':') < AUTH_READ) return false;
467
468            //Open requested level
469            if ($opts['level'] > $lvl || $opts['level'] == -1) {
470                $isOpen = true;
471            }
472
473            //Search optional subnamespaces with
474            $isFolderAdjacentToSubNss = false;
475            if (!empty($opts['subnss'])) {
476                $subnss = $opts['subnss'];
477                $counter = count($subnss);
478
479                for ($a = 0; $a < $counter; $a++) {
480                    if (preg_match("/^" . $id . "($|:.+)/i", $subnss[$a][0], $match)) {
481                        //this folder contains a subnamespace
482                        $isOpen = true;
483                    } elseif (preg_match("/^" . $subnss[$a][0] . "(:.*)/i", $id, $match)) {
484                        //this folder is inside a subnamespace, check level
485                        if ($subnss[$a][1] == -1 || substr_count($match[1], ":") < $subnss[$a][1]) {
486                            $isOpen = true;
487                        } else {
488                            $isOpen = false;
489                        }
490                    } elseif (
491                        preg_match(
492                            "/^" . (($ns = getNS($id)) === false ? '' : $ns) . "($|:.+)/i",
493                            $subnss[$a][0],
494                            $match
495                        )
496                    ) {
497                        // parent folder contains a subnamespace, if level deeper it does not match anymore
498                        // that is handled with normal >max handling
499                        $isOpen = false;
500                        if ($opts['max'] > 0) {
501                            $isFolderAdjacentToSubNss = true;
502                        }
503                    }
504                }
505            }
506
507            //decide if it should be traversed
508            if ($opts['nons']) {
509                return $isOpen; // in nons, level is only way to show/hide nodes (in nons nodes are not expandable)
510            } elseif ($opts['max'] > 0 && !$isOpen) { // note: for Fancytree >=1 is used
511                // limited levels per request, node is closed
512                if ($lvl == $opts['max'] || $isFolderAdjacentToSubNss) {
513                    // change type, more nodes should be loaded by ajax, but for nopg we need extra level to determine
514                    // if folder is empty
515                    // and folders adjacent to subns must be traversed as well
516                    $type = "l";
517                    $shouldBeTraversed = true;
518                } elseif ($lvl > $opts['max']) { // deeper lvls only used temporary for checking existance children
519                    //change type, more nodes should be loaded by ajax
520                    $type = "l"; // use lazy loading
521                    $shouldBeTraversed = false;
522                } else {
523                    //node is closed, but still more levels requested with max
524                    $shouldBeTraversed = true;
525                }
526            } else {
527                $shouldBeTraversed = $isOpen;
528            }
529
530            //Set title and headpage
531            $title = static::getNamespaceTitle($id, $headpage, $hns);
532
533            // when excluding page nodes: guess a headpage based on the headpage setting
534            if ($opts['nopg'] && $hns === false) {
535                $hns = $this->guessHeadpage($headpage, $id);
536                $hnsExists = false;
537            }
538        } else {
539            //Nopg.Dont show pages
540            if ($opts['nopg']) return false;
541
542            $shouldBeTraversed = true;
543            //Nons.Set all pages at first level
544            if ($opts['nons']) {
545                $lvl = 1;
546            }
547            //don't add
548            if (substr($file, -4) != '.txt') return false;
549            //check hiddens and acl
550            if (isHiddenPage($id) || auth_quickaclcheck($id) < AUTH_READ) return false;
551            //Skip files in plugin conf
552            foreach ($skipfile as $skipf) {
553                if (!empty($skipf) && preg_match($skipf, $id)) {
554                    return false;
555                }
556            }
557            //Skip headpages to hide
558            if (!$opts['nons'] && !empty($headpage) && $opts['hide_headpage']) {
559                //start page is in root
560                if ($id == $conf['start']) return false;
561
562                $hpOptions = explode(",", $headpage);
563                foreach ($hpOptions as $hp) {
564                    switch ($hp) {
565                        case ":inside:":
566                            if (noNS($id) == noNS(getNS($id))) return false;
567                            break;
568                        case ":same:":
569                            if (@is_dir(dirname(wikiFN($id)) . "/" . utf8_encodeFN(noNS($id)))) return false;
570                            break;
571                        //it' s an inside start
572                        case ":start:":
573                            if (noNS($id) == $conf['start']) return false;
574                            break;
575                        default:
576                            if (noNS($id) == cleanID($hp)) return false;
577                    }
578                }
579            }
580
581            //Set title
582            if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') {
583                $title = p_get_first_heading($id, false);
584            }
585            if (is_null($title)) {
586                $title = noNS($id);
587            }
588            $title = hsc($title);
589        }
590
591        $item = [
592            'id' => $id,
593            'type' => $type,
594            'level' => $lvl,
595            'open' => $isOpen,
596            'title' => $title,
597            'hns' => $hns,
598            'hnsExists' => $hnsExists,
599            'file' => $file,
600            'shouldBeTraversed' => $shouldBeTraversed
601        ];
602        $item['sort'] = $this->getSortValue($item);
603        $data[] = $item;
604
605        return $shouldBeTraversed;
606    }
607
608    /**
609     * callback that recurse directory
610     *
611     * This function recurses into a given base directory
612     * and calls the supplied function for each file and directory
613     *
614     * Similar to search() of inc/search.php, but has extended sorting options
615     *
616     * @param array $data The results of the search are stored here
617     * @param string $base Where to start the search
618     * @param callback $func Callback (function name or array with object,method)
619     * @param array $opts List of indexmenu options
620     * @param string $dir Current directory beyond $base
621     * @param int $lvl Recursion Level
622     *
623     * @author  Andreas Gohr <andi@splitbrain.org>
624     * @author  modified by Samuele Tognini <samuele@samuele.netsons.org>
625     */
626    public function customSearch(&$data, $base, $func, $opts, $dir = '', $lvl = 1)
627    {
628        $dirs = [];
629        $files = [];
630        $files_tmp = [];
631        $dirs_tmp = [];
632        $count = count($data);
633
634        //read in directories and files
635        $dh = @opendir($base . '/' . $dir);
636        if (!$dh) return;
637        while (($file = readdir($dh)) !== false) {
638            //skip hidden files and upper dirs
639            if (preg_match('/^[._]/', $file)) continue;
640            if (is_dir($base . '/' . $dir . '/' . $file)) {
641                $dirs[] = $dir . '/' . $file;
642                continue;
643            }
644            $files[] = $dir . '/' . $file;
645        }
646        closedir($dh);
647
648        //Collect and sort files
649        foreach ($files as $file) {
650            call_user_func_array($func, [&$files_tmp, $base, $file, 'f', $lvl, $opts]);
651        }
652        usort($files_tmp, [$this, "compareNodes"]);
653
654        //Collect and sort dirs
655        if ($this->nsort) {
656            //collect the wanted directories in dirs_tmp
657            foreach ($dirs as $dir) {
658                call_user_func_array($func, [&$dirs_tmp, $base, $dir, 'd', $lvl, $opts]);
659            }
660            //combine directories and pages and sort together
661            $dirsAndFiles = array_merge($dirs_tmp, $files_tmp);
662            usort($dirsAndFiles, [$this, "compareNodes"]);
663
664            //add and search each directory
665            foreach ($dirsAndFiles as $dirOrFile) {
666                $data[] = $dirOrFile;
667                if ($dirOrFile['type'] != 'f' && $dirOrFile['shouldBeTraversed']) {
668                    $this->customSearch($data, $base, $func, $opts, $dirOrFile['file'], $lvl + 1);
669                }
670            }
671        } else {
672            //sort by directory name
673            Sort::sort($dirs);
674            //collect directories
675            foreach ($dirs as $dir) {
676                if (call_user_func_array($func, [&$data, $base, $dir, 'd', $lvl, $opts])) {
677                    $this->customSearch($data, $base, $func, $opts, $dir, $lvl + 1);
678                }
679            }
680        }
681
682        //count added items
683        $added = count($data) - $count;
684
685        if ($added === 0 && $files_tmp === []) {
686            //remove empty directory again, only if it has not a headpage associated
687            $lastItem = end($data);
688            if (!$lastItem['hns']) {
689                array_pop($data);
690            }
691        } elseif (!$this->nsort) {
692            //add files to index
693            $data = array_merge($data, $files_tmp);
694        }
695    }
696
697
698    /**
699     * Get namespace title, checking for headpages
700     *
701     * @param string $ns namespace
702     * @param string $headpage comma-separated headpages options and headpages
703     * @param string|false $hns reference pageid of headpage, false when not existing
704     * @return string when headpage & heading on: title of headpage, otherwise: namespace name
705     *
706     * @author  Samuele Tognini <samuele@samuele.netsons.org>
707     */
708    public static function getNamespaceTitle($ns, $headpage, &$hns)
709    {
710        global $conf;
711        $hns = false;
712        $title = noNS($ns);
713        if (empty($headpage)) {
714            return $title;
715        }
716        $hpOptions = explode(",", $headpage);
717        foreach ($hpOptions as $hp) {
718            switch ($hp) {
719                case ":inside:":
720                    $page = $ns . ":" . noNS($ns);
721                    break;
722                case ":same:":
723                    $page = $ns;
724                    break;
725                //it's an inside start
726                case ":start:":
727                    $page = ltrim($ns . ":" . $conf['start'], ":");
728                    break;
729                //inside pages
730                default:
731                    if (!blank($hp)) { //empty setting results in empty string here
732                        $page = $ns . ":" . $hp;
733                    }
734            }
735            //check headpage
736            if (@file_exists(wikiFN($page)) && auth_quickaclcheck($page) >= AUTH_READ) {
737                if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') {
738                    $title_tmp = p_get_first_heading($page, false);
739                    if (!is_null($title_tmp)) {
740                        $title = $title_tmp;
741                    }
742                }
743                $title = hsc($title);
744                $hns = $page;
745                //headpage found, exit for
746                break;
747            }
748        }
749        return $title;
750    }
751
752
753    /**
754     * callback that sorts nodes
755     *
756     * @param array $a first node as array with 'sort' entry
757     * @param array $b second node as array with 'sort' entry
758     * @return int if less than zero 1st node is less than 2nd, otherwise equal respectively larger
759     */
760    private function compareNodes($a, $b)
761    {
762        if ($this->rsort) {
763            return Sort::strcmp($b['sort'], $a['sort']);
764        } else {
765            return Sort::strcmp($a['sort'], $b['sort']);
766        }
767    }
768
769    /**
770     * Add sort information to item.
771     *
772     * @param array $item
773     * @return bool|int|mixed|string
774     *
775     * @author  Samuele Tognini <samuele@samuele.netsons.org>
776     */
777    private function getSortValue($item)
778    {
779        global $conf;
780
781        $sort = false;
782        $page = false;
783        if ($item['type'] == 'd' || $item['type'] == 'l') {
784            //Fake order info when nsort is not requested
785            if ($this->nsort) {
786                $page = $item['hns'];
787            } else {
788                $sort = 0;
789            }
790        }
791        if ($item['type'] == 'f') {
792            $page = $item['id'];
793        }
794        if ($page) {
795            if ($this->hsort && noNS($item['id']) == $conf['start']) {
796                $sort = 1;
797            }
798            if ($this->msort) {
799                $sort = p_get_metadata($page, $this->msort);
800            }
801            if (!$sort && $this->sort) {
802                switch ($this->sort) {
803                    case 't':
804                        $sort = $item['title'];
805                        break;
806                    case 'd':
807                        $sort = @filectime(wikiFN($page));
808                        break;
809                }
810            }
811        }
812        if ($sort === false) {
813            $sort = noNS($item['id']);
814        }
815        return $sort;
816    }
817
818    /**
819     * Guess based on first option of the headpage config setting (default :start: if enabled) the headpage of the node
820     *
821     * @param string $headpage config setting
822     * @param string $ns namespace
823     * @return string guessed headpage
824     */
825    private function guessHeadpage(string $headpage, string $ns): string
826    {
827        global $conf;
828        $hns = false;
829
830        $hpOptions = explode(",", $headpage);
831        foreach ($hpOptions as $hp) {
832            switch ($hp) {
833                case ":inside:":
834                    $hns = $ns . ":" . noNS($ns);
835                    break 2;
836                case ":same:":
837                    $hns = $ns;
838                    break 2;
839                //it's an inside start
840                case ":start:":
841                    $hns = ltrim($ns . ":" . $conf['start'], ":");
842                    break 2;
843                //inside pages
844                default:
845                    if (!blank($hp)) {
846                        $hns = $ns . ":" . $hp;
847                        break 2;
848                    }
849            }
850        }
851
852        if ($hns === false) {
853            //fallback to start if headpage setting was empty
854            $hns = ltrim($ns . ":" . $conf['start'], ":");
855        }
856        return $hns;
857    }
858}
859