1<?php
2/**
3 * Plugin nspages : Displays nicely a list of the pages of a namespace
4 *
5 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 */
7
8if(!defined('DOKU_INC')) die();
9require_once 'printer.php';
10
11class nspages_printerTree extends nspages_printer {
12    private $rootNS;
13
14    function __construct($plugin, $mode, $renderer, $data){
15        parent::__construct($plugin, $mode, $renderer, $data);
16        $this->rootNS = $data['wantedNS'] . ':';
17    }
18
19    function _print($tab, $type) {
20        $tree = $this->_groupByNs($tab);
21        $trimmedTree = $this->_getTrimmedTree($tree);
22        $orderedTree = $this->_orderTree($trimmedTree);
23        $this->_printTree($orderedTree);
24    }
25
26    /**
27     * We received the nodes all ordered together, but building the tree has probably
28     * lost the order for namespaces, we hence need to sort again each node
29     */
30    function _orderTree($tree) {
31        // We only need to sort "children". We don't need to sort "pages" because with the current
32        // workflow of the plugin nodes are provided already sorted to _print, and the way we
33        // build the tree preserves the order of the pages.
34        // An optimization could be to disable the preliminary sort and to instead sort pages here.
35        // That could save some CPU cycles because instead of sorting a big list we would sort
36        // several smaller ones. However it would require
37        // - a regression test which assert on the order of the pages when a flag is passed to
38        //   have a custom sort (eg: "-h1") to ensure we don't have the correct order just because
39        //   the DW search API returned sorted results based on the id of the pages
40        // - benchmarking (because it could be detrimental if usort has a constant overhead which
41        //   would make several small sort more costly than a single one bigger)
42        $this->_sorter->sort($tree->children);
43
44        foreach($tree->children as $subTree){
45            $this->_orderTree($subTree);
46        }
47        return $tree;
48    }
49
50    private function _groupByNs($tab) {
51        $tree = new NspagesTreeNsNode(':');
52        foreach($tab as $item){
53            $this->_fillTree($tree, $this->_getNS($item), $item, '');
54        }
55        return $tree;
56    }
57
58    /**
59     * Get rid of the "trunk" of the tree. ie: remove the first "empty" nodes. It prevents printing
60     * something like
61     * - A
62     *  - B
63     *    - C
64     *      - page1
65     *      - page2
66     *      - page3
67     * when the ns the user asked for is actully ns C
68     */
69    private function _getTrimmedTree($tree){
70        if ($tree->id === $this->rootNS){
71            return $tree;
72        } else {
73            if (is_null($tree->children)) {
74                // This case should never happen. But I handle it neverthelss because if I'm wrong
75                // then the recursion will never end
76                return $tree;
77            }
78            $firstAndOnlyChild = reset($tree->children);
79            return $this->_getTrimmedTree($firstAndOnlyChild);
80        }
81    }
82
83    private function _getNS($item) {
84        if($item['type'] === 'd'){
85            // If $item is itself a namespace then:
86            // - its 'id' will look like either:
87            //   1. 'a:b:c:' if the ns has no main page
88            //   2. 'a:b:c:start' or 'a:b:c:c' (if this page exists)
89            //   3. 'a:b:c' (case where there is a page a:b:c and no page a:b:c:start, see bug #120)
90            // - its 'ns' will look like 'a:b'
91            // What we want is array ['a', 'b', 'c']
92
93            // For a page at the root of the repo:
94            // - the 'id' will look like either
95            //   4. 'a:start' in most cases
96            //   5. 'a' (case where the is a page 'a' and no page 'a:start', see bug #120)
97            // - the 'ns' will be FALSE
98
99            $lastChar = substr($item['id'], -1);
100            $IdSplit = explode(':', $item['id']);
101
102            if ($item['ns'] !== false){
103                if ($lastChar === ':' // case 1
104                  || count(explode(':', $item['ns'])) === count($IdSplit) -2){ // case 2
105                    array_pop($IdSplit);
106                } else { // case 3 (nothing to do here)
107                }
108            } else {
109                if ($this->str_contains($item['id'], ':')){ // case 4
110                    array_pop($IdSplit);
111                } else { // case 5 (nothing to do here)
112                }
113            }
114
115            return $IdSplit;
116        } else {
117            // It $item is a page then:
118            // - its 'id' will look like 'a:b:page'
119            // - its 'ns' will look like 'a:b'
120            // What we want is array ['a', 'b']
121            if ($item['ns'] === false) {
122              // Special case of the pages at the root of the wiki: for them "ns" is set to boolean FALSE
123              return array();
124            } else {
125              return explode(':', $item['ns']);
126            }
127        }
128    }
129
130    /**
131     * This is similar to https://www.php.net/manual/en/function.str-contains.php, but the PHP str_contains
132     * method is available only from PHP 8 so for now we re-implement this feature
133     */
134    private function str_contains(string $haystack, string $needle){
135        return strpos($haystack, $needle) !== false;
136    }
137
138    private function _fillTree($tree, $keys, $item, $parentId) {
139        if (empty($keys)){ // We've reach the end of the journey. We register the data of $item
140            if($item['type'] === 'd') {
141                $tree->self = $item;
142            } else {
143                $tree->pages []= $item;
144            }
145        } else { // We're not at the place of $item in the tree yet, we continue to go down
146            $key = $keys[0];
147            $currentId = $parentId . $key . ':';
148            if (!array_key_exists($key, $tree->children)){
149                $node = new NspagesTreeNsNode($currentId);
150                $tree->children[$key] = $node;
151            }
152            array_shift($keys);
153            $this->_fillTree($tree->children[$key], $keys, $item, $currentId);
154        }
155    }
156
157    private function _printTree($tree) {
158        $this->renderer->listu_open();
159
160        foreach($tree->children as $subTree){
161            $this->_printSubTree($subTree, 1);
162        }
163
164         foreach($tree->pages as $page){
165             $this->_printElement($page, 1);
166         }
167
168         $this->renderer->listu_close();
169    }
170
171    private function _printSubTree($tree, $level) {
172        $this->_printElementOpen($tree->self, $level);
173        if ( !is_null($tree->self) ){
174            $this->_printElementContent($tree->self, $level);
175        } else {
176          $this->renderer->doc .= '<div>' . $tree->id  . '</div>';
177        }
178
179        $hasInnerData = !empty($tree->children) || !empty($tree->pages);
180        if($hasInnerData){
181            $this->renderer->listu_open();
182        }
183        foreach($tree->children as $subTree){
184            $this->_printSubTree($subTree, $level+1);
185        }
186        foreach($tree->pages as $page){
187            $this->_printElement($page, $level+1);
188        }
189        if($hasInnerData){
190            $this->renderer->listu_close();
191        }
192        $this->_printElementClose();
193    }
194}
195
196/**
197 * Represent a namespace and its inner content
198 */
199class NspagesTreeNsNode implements ArrayAccess {
200    /**
201     * The list of pages directly in the namespace (does not include pages in subnamespaces)
202     */
203    public $pages = array();
204
205    /**
206     * The list of subnamespaces at level n+1 (does not include their own subnamespaces)
207     */
208    public $children = array();
209
210    /**
211     * The data about the current namespace iteslf. It may be empty in two cases:
212     * - when nspages is displaying only pages (because in that case we did not search for ns)
213     * - when this instance represents the root of the tree (because nspages doesn't display it)
214     */
215    public $self = null;
216
217    /**
218     * Used to represent the current namespace when we're in a case where we want to display it
219     * but when $self is empty.
220     * In practice it is used to represent namespace nodes when we're asked to display pages only
221     */
222    public $id = null;
223
224    function __construct($id){
225        $this->id = $id;
226    }
227
228    /**
229     * Implement ArrayAccess because instances of this class should be sortable with nspages_sorter
230     * implementations and that those implementation are performing sorts based on $item["sort"].
231     */
232    public function offsetSet($offset, $value) {
233        throw new BadMethodCallException("Not implemented by design");
234    }
235    public function offsetExists($offset) {
236        return $offset == "sort";
237    }
238    public function offsetUnset($offset) {
239        throw new BadMethodCallException("Not implemented by design");
240    }
241    public function offsetGet($offset) {
242      return is_null($this->self) ?
243            $this->id :
244            $this->self["sort"];
245    }
246}
247