1<?php
2
3use dokuwiki\Extension\SyntaxPlugin;
4use dokuwiki\File\PageResolver;
5use dokuwiki\Utf8\Sort;
6
7/**
8 * DokuWiki Plugin simplenavi (Syntax Component)
9 *
10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
11 * @author  Andreas Gohr <gohr@cosmocode.de>
12 */
13class syntax_plugin_simplenavi extends SyntaxPlugin
14{
15    private $startpages = [];
16
17    /** @inheritdoc */
18    public function getType()
19    {
20        return 'substition';
21    }
22
23    /** @inheritdoc */
24    public function getPType()
25    {
26        return 'block';
27    }
28
29    /** @inheritdoc */
30    public function getSort()
31    {
32        return 155;
33    }
34
35    /** @inheritdoc */
36    public function connectTo($mode)
37    {
38        $this->Lexer->addSpecialPattern('{{simplenavi>[^}]*}}', $mode, 'plugin_simplenavi');
39    }
40
41    /** @inheritdoc */
42    public function handle($match, $state, $pos, Doku_Handler $handler)
43    {
44        return explode(' ', substr($match, 13, -2));
45    }
46
47    /** @inheritdoc */
48    public function render($format, Doku_Renderer $renderer, $data)
49    {
50        if ($format != 'xhtml') return false;
51
52        global $INFO;
53        $renderer->nocache();
54
55        // first data is namespace, rest is options
56        $ns = array_shift($data);
57        if ($ns && $ns[0] === '.') {
58            // resolve relative to current page
59            $ns = getNS((new PageResolver($INFO['id']))->resolveId("$ns:xxx"));
60        } else {
61            $ns = cleanID($ns);
62        }
63
64        $items = $this->getSortedItems(
65            $ns,
66            $INFO['id'],
67            $this->getConf('usetitle'),
68            $this->getConf('natsort'),
69            $this->getConf('nsfirst'),
70            in_array('home', $data)
71        );
72
73        $class = 'plugin__simplenavi';
74        if (in_array('filter', $data)) $class .= ' plugin__simplenavi_filter';
75
76        $renderer->doc .= '<div class="' . $class . '">';
77        $renderer->doc .= html_buildlist($items, 'idx', [$this, 'cbList'], [$this, 'cbListItem']);
78        $renderer->doc .= '</div>';
79
80        return true;
81    }
82
83    /**
84     * Fetch the items to display
85     *
86     * This returns a flat list suitable for html_buildlist()
87     *
88     * @param string $ns the namespace to search in
89     * @param string $current the current page, the tree will be expanded to this
90     * @param bool $useTitle Sort by the title instead of the ID?
91     * @param bool $useNatSort Use natural sorting or just sort by ASCII?
92     * @param bool $nsFirst Sort namespaces before pages?
93     * @param bool $home Add namespace's start page as top level item?
94     * @return array
95     */
96    public function getSortedItems($ns, $current, $useTitle, $useNatSort, $nsFirst, $home)
97    {
98        global $conf;
99
100        // convert to path
101        $nspath = utf8_encodeFN(str_replace(':', '/', $ns));
102
103        // get the start page of the main namespace, this adds it to the list of seen pages in $this->startpages
104        // and will skip it by default in the search callback
105        $startPage = $this->getMainStartPage($ns, $useTitle);
106
107        $items = [];
108        if ($home) {
109            // when home is requested, add the start page as top level item
110            $items[$startPage['id']] = $startPage;
111            $minlevel = 0;
112        } else {
113            $minlevel = 1;
114        }
115
116        // execute search using our own callback
117        search(
118            $items,
119            $conf['datadir'],
120            [$this, 'cbSearch'],
121            [
122                'currentID' => $current,
123                'usetitle' => $useTitle,
124            ],
125            $nspath,
126            1,
127            '' // no sorting, we do ourselves
128        );
129        if (!$items) return [];
130
131        // split into separate levels
132        $parents = [];
133        $levels = [];
134        $curLevel = $minlevel;
135        foreach ($items as $idx => $item) {
136            if ($curLevel < $item['level']) {
137                // previous item was the parent
138                $parents[] = array_key_last($levels[$curLevel]);
139            }
140            $curLevel = $item['level'];
141            $levels[$item['level']][$idx] = $item;
142        }
143
144        // sort each level separately
145        foreach ($levels as $level => $items) {
146            uasort($items, function ($a, $b) use ($useNatSort, $nsFirst) {
147                return $this->itemComparator($a, $b, $useNatSort, $nsFirst);
148            });
149            $levels[$level] = $items;
150        }
151
152        // merge levels into a flat list again
153        $levels = array_reverse($levels, true);
154        foreach (array_keys($levels) as $level) {
155            if ($level == $minlevel) break;
156
157            $parent = array_pop($parents);
158            $pos = array_search($parent, array_keys($levels[$level - 1])) + 1;
159
160            /** @noinspection PhpArrayAccessCanBeReplacedWithForeachValueInspection */
161            $levels[$level - 1] = array_slice($levels[$level - 1], 0, $pos, true) +
162                $levels[$level] +
163                array_slice($levels[$level - 1], $pos, null, true);
164        }
165
166        return $levels[$minlevel];
167    }
168
169    /**
170     * Compare two items
171     *
172     * @param array $a
173     * @param array $b
174     * @param bool $useNatSort
175     * @param bool $nsFirst
176     * @return int
177     */
178    public function itemComparator($a, $b, $useNatSort, $nsFirst)
179    {
180        if ($nsFirst && $a['type'] != $b['type']) {
181            return $a['type'] == 'd' ? -1 : 1;
182        }
183
184        if ($useNatSort) {
185            return Sort::strcmp($a['title'], $b['title']);
186        } else {
187            return strcmp($a['title'], $b['title']);
188        }
189    }
190
191
192    /**
193     * Create a list openening
194     *
195     * @param array $item
196     * @return string
197     * @see html_buildlist()
198     */
199    public function cbList($item)
200    {
201        global $INFO;
202
203        if (($item['type'] == 'd' && $item['open']) || $INFO['id'] == $item['id']) {
204            return '<strong>' . html_wikilink(':' . $item['id'], $item['title']) . '</strong>';
205        } else {
206            return html_wikilink(':' . $item['id'], $item['title']);
207        }
208    }
209
210    /**
211     * Create a list item
212     *
213     * @param array $item
214     * @return string
215     * @see html_buildlist()
216     */
217    public function cbListItem($item)
218    {
219        if ($item['type'] == "f") {
220            return '<li class="level' . $item['level'] . '">';
221        } elseif ($item['open']) {
222            return '<li class="open">';
223        } else {
224            return '<li class="closed">';
225        }
226    }
227
228    /**
229     * Custom search callback
230     *
231     * @param $data
232     * @param $base
233     * @param $file
234     * @param $type
235     * @param $lvl
236     * @param array $opts - currentID is the currently shown page
237     * @return bool
238     */
239    public function cbSearch(&$data, $base, $file, $type, $lvl, $opts)
240    {
241        global $conf;
242        $return = true;
243
244        $id = pathID($file);
245
246        if (
247            $type == 'd' &&
248            (
249                !preg_match('#^' . $id . '(:|$)#', $opts['currentID']) &&
250                !preg_match('#^' . $id . '(:|$)#', getNS($opts['currentID']))
251            )
252        ) {
253            //add but don't recurse
254            $return = false;
255        } elseif ($type == 'f' && (!empty($opts['nofiles']) || substr($file, -4) != '.txt')) {
256            //don't add
257            return false;
258        }
259
260        // for sneaky index, check access to the namespace's start page
261        if ($type == 'd' && $conf['sneaky_index']) {
262            $sp = (new PageResolver(''))->resolveId($id . ':');
263            if (auth_quickaclcheck($sp) < AUTH_READ) {
264                return false;
265            }
266        }
267
268        if ($type == 'd') {
269            // link directories to their start pages
270            $original = $id;
271            $id = "$id:";
272            $id = (new PageResolver(''))->resolveId($id);
273            $this->startpages[$id] = 1;
274
275            // if the resolve id is in the same namespace as the original it's a start page named like the dir
276            if (getNS($original) === getNS($id)) {
277                $useNS = $original;
278            }
279        } elseif (!empty($this->startpages[$id])) {
280            // skip already shown start pages
281            return false;
282        }
283
284        //check hidden
285        if (isHiddenPage($id)) {
286            return false;
287        }
288
289        //check ACL
290        if ($type == 'f' && auth_quickaclcheck($id) < AUTH_READ) {
291            return false;
292        }
293
294        $data[$id] = [
295            'id' => $id,
296            'type' => $type,
297            'level' => $lvl,
298            'open' => $return,
299            'title' => $this->getTitle($id, $opts['usetitle']),
300            'ns' => $useNS ?? (string)getNS($id),
301        ];
302
303        return $return;
304    }
305
306    /**
307     * @param string $id
308     * @param bool $useTitle
309     * @return array
310     */
311    protected function getMainStartPage($ns, $useTitle)
312    {
313        $resolver = new PageResolver('');
314        $id = $resolver->resolveId($ns . ':');
315
316        $item = [
317            'id' => $id,
318            'type' => 'd',
319            'level' => 0,
320            'open' => true,
321            'title' => $this->getTitle($id, $useTitle),
322            'ns' => $ns,
323        ];
324        $this->startpages[$id] = 1;
325        return $item;
326    }
327
328    /**
329     * Get the title for the given page ID
330     *
331     * @param string $id
332     * @param bool $usetitle - use the first heading as title
333     * @return string
334     */
335    protected function getTitle($id, $usetitle)
336    {
337        global $conf;
338
339        if ($usetitle) {
340            $p = p_get_first_heading($id);
341            if (!empty($p)) return $p;
342        }
343
344        $p = noNS($id);
345        if ($p == $conf['start'] || !$p) {
346            $p = noNS(getNS($id));
347            if (!$p) {
348                return $conf['start'];
349            }
350        }
351        return $p;
352    }
353}
354