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