1<?php
2/**
3 * Build a navigation menu from a list
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Andreas Gohr <gohr@cosmocode.de>
7 */
8class syntax_plugin_navi extends DokuWiki_Syntax_Plugin
9{
10    protected $defaultOptions = [
11        'ns' => false,
12        'full' => false,
13        'js' => false,
14    ];
15
16    /** * @inheritDoc */
17    public function getType()
18    {
19        return 'substition';
20    }
21
22    /** * @inheritDoc */
23    public function getPType()
24    {
25        return 'block';
26    }
27
28    /** * @inheritDoc */
29    public function getSort()
30    {
31        return 155;
32    }
33
34    /** * @inheritDoc */
35    public function connectTo($mode)
36    {
37        $this->Lexer->addSpecialPattern('{{navi>[^}]+}}', $mode, 'plugin_navi');
38    }
39
40    /** * @inheritDoc */
41    public function handle($match, $state, $pos, Doku_Handler $handler)
42    {
43        $id = substr($match, 7, -2);
44        $opts = '';
45        if (strpos($id, '?') !== false) {
46            list($id, $opts) = explode('?', $id, 2);
47        }
48        $options = $this->parseOptions($opts);
49        $list = $this->parseNavigationControlPage(cleanID($id));
50
51        return [wikiFN($id), $list, $options];
52    }
53
54    /**
55     * @inheritDoc
56     * We handle all modes (except meta) because we pass all output creation back to the parent
57     */
58    public function render($format, Doku_Renderer $R, $data)
59    {
60        $fn = $data[0];
61        $navItems = $data[1];
62        $options = $data[2];
63
64        if ($format == 'metadata') {
65            $R->meta['relation']['naviplugin'][] = $fn;
66            return true;
67        }
68
69        $R->info['cache'] = false; // no cache please
70
71        $parentPath = $this->getOpenPath($navItems, $options);
72
73        $R->doc .= '<div class="plugin__navi ' . ($options['js'] ? 'js' : '') . '">';
74        $this->renderTree($navItems, $parentPath, $R, $options['full']);
75        $R->doc .= '</div>';
76
77        return true;
78    }
79
80    /**
81     * Simple accessor to call the plugin from templates
82     *
83     * @param string $controlPage
84     * @param array $options
85     * @return string the HTML tree
86     */
87    public function tpl($controlPage, $options = [])
88    {
89        // resolve relative to the controlpage because we have no sidebar context
90        global $ID;
91        $oldid = $ID;
92        $ID = $controlPage;
93
94        $options = array_merge($this->defaultOptions, $options);
95        $R = new \Doku_Renderer_xhtml();
96        $this->render('xhtml', $R, [
97            wikiFN($controlPage),
98            $this->parseNavigationControlPage($controlPage),
99            $options,
100        ]);
101
102        $ID = $oldid;
103        return $R->doc;
104    }
105
106    /**
107     * Parses the items from the control page
108     *
109     * @param string $controlPage ID of the control page
110     * @return array list of navigational items
111     */
112    public function parseNavigationControlPage($controlPage)
113    {
114        global $ID;
115
116        // fetch the instructions of the control page
117        $instructions = p_cached_instructions(wikiFN($controlPage), false, $controlPage);
118        if (!$instructions) return [];
119
120        // prepare some vars
121        $max = count($instructions);
122        $pre = true;
123        $lvl = 0;
124        $parents = array();
125        $page = '';
126        $cnt = 0;
127
128        // build a lookup table
129        $list = [];
130        for ($i = 0; $i < $max; $i++) {
131            if ($instructions[$i][0] == 'listu_open') {
132                $pre = false;
133                $lvl++;
134                if ($page) array_push($parents, $page);
135            } elseif ($instructions[$i][0] == 'listu_close') {
136                $lvl--;
137                array_pop($parents);
138            } elseif ($pre || $lvl == 0) {
139                unset($instructions[$i]);
140            } elseif ($instructions[$i][0] == 'listitem_close') {
141                $cnt++;
142            } elseif ($instructions[$i][0] == 'internallink') {
143                $foo = true;
144                $page = $instructions[$i][1][0];
145                resolve_pageid(getNS($ID), $page, $foo); // resolve relative to sidebar ID
146                $list[$page] = array(
147                    'parents' => $parents,
148                    'page' => $page,
149                    'title' => $instructions[$i][1][1],
150                    'lvl' => $lvl,
151                );
152            } elseif ($instructions[$i][0] == 'externallink') {
153                $url = $instructions[$i][1][0];
154                $list['_' . $page] = array(
155                    'parents' => $parents,
156                    'page' => $url,
157                    'title' => $instructions[$i][1][1],
158                    'lvl' => $lvl,
159                );
160            }
161        }
162        return $list;
163    }
164
165    /**
166     * Create a "path" of items to be opened above the current page
167     *
168     * @param array $navItems list of navigation items
169     * @param array $options Configuration options
170     * @return array
171     */
172    public function getOpenPath($navItems, $options)
173    {
174        global $INFO;
175        $openPath = array();
176        if (isset($navItems[$INFO['id']])) {
177            $openPath = (array)$navItems[$INFO['id']]['parents']; // get the "path" of the page we're on currently
178            array_push($openPath, $INFO['id']);
179        } elseif ($options['ns']) {
180            $ns = $INFO['id'];
181
182            // traverse up for matching namespaces
183            if ($navItems) {
184                do {
185                    $ns = getNS($ns);
186                    $try = "$ns:";
187                    resolve_pageid('', $try, $foo);
188                    if (isset($navItems[$try])) {
189                        // got a start page
190                        $openPath = (array)$navItems[$try]['parents'];
191                        array_push($openPath, $try);
192                        break;
193                    } else {
194                        // search for the first page matching the namespace
195                        foreach ($navItems as $key => $junk) {
196                            if (getNS($key) == $ns) {
197                                $openPath = (array)$navItems[$key]['parents'];
198                                array_push($openPath, $key);
199                                break 2;
200                            }
201                        }
202                    }
203
204                } while ($ns);
205            }
206        }
207        return $openPath;
208    }
209
210    /**
211     * create a correctly nested list (or so I hope)
212     *
213     * @param array $navItems list of navigational items
214     * @param array $parentPath path of parent items
215     * @param Doku_Renderer $R should closed subitems still be rendered?
216     * @param bool $fullTree
217     */
218    public function renderTree($navItems, $parentPath, Doku_Renderer $R, $fullTree = false)
219    {
220        $open = false;
221        $lvl = 1;
222        $R->listu_open();
223
224        // read if item has childs and if it is open or closed
225        $upper = array();
226        foreach ((array)$navItems as $pid => $info) {
227            $state = (array_diff($info['parents'], $parentPath)) ? 'close' : '';
228            $countparents = count($info['parents']);
229            if ($countparents > '0') {
230                for ($i = 0; $i < $countparents; $i++) {
231                    $upperlevel = $countparents - 1;
232                    $upper[$info['parents'][$upperlevel]] = ($state == 'close') ? 'close' : 'open';
233                }
234            }
235        }
236        unset($pid);
237
238        foreach ((array)$navItems as $pid => $info) {
239            // only show if we are in the "path"
240            if (!$fullTree && array_diff($info['parents'], $parentPath)) {
241                continue;
242            }
243
244            if (!empty($upper[$pid])) {
245                $menuitem = ($upper[$pid] == 'open') ? 'open' : 'close';
246            } else {
247                $menuitem = '';
248            }
249
250            // skip every non readable page
251            if (auth_quickaclcheck(cleanID($info['page'])) < AUTH_READ) {
252                continue;
253            }
254
255            if ($info['lvl'] == $lvl) {
256                if ($open) {
257                    $R->listitem_close();
258                }
259                $R->listitem_open($lvl . ' ' . $menuitem);
260                $open = true;
261            } elseif ($lvl > $info['lvl']) {
262                for (; $lvl > $info['lvl']; --$lvl) {
263                    $R->listitem_close();
264                    $R->listu_close();
265                }
266                $R->listitem_close();
267                $R->listitem_open($lvl . ' ' . $menuitem);
268            } elseif ($lvl < $info['lvl']) {
269                // more than one run is bad nesting!
270                for (; $lvl < $info['lvl']; ++$lvl) {
271                    $R->listu_open();
272                    $R->listitem_open($lvl + 1 . ' ' . $menuitem);
273                    $open = true;
274                }
275            }
276
277            $R->listcontent_open();
278            if (substr($pid, 0, 1) != '_') {
279                $R->internallink(':' . $info['page'], $info['title']);
280            } else {
281                $R->externallink($info['page'], $info['title']);
282            }
283
284            $R->listcontent_close();
285        }
286        while ($lvl > 0) {
287            $R->listitem_close();
288            $R->listu_close();
289            --$lvl;
290        }
291    }
292
293    /**
294     * Parse the option string into an array
295     *
296     * @param string $opts
297     * @return array
298     */
299    protected function parseOptions($opts)
300    {
301        $options = $this->defaultOptions;
302
303        foreach (explode('&', $opts) as $opt) {
304            $options[$opt] = true;
305        }
306
307        if ($options['js']) $options['full'] = true;
308
309        return $options;
310    }
311}
312