1<?php
2/**
3 * DokuWiki Plugin docnav (Syntax Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Gerrit Uitslag <klapinklapin@gmail.com>
7 */
8
9use dokuwiki\Extension\SyntaxPlugin;
10
11/**
12 * Syntax for including a table of content of bundle of pages linked by docnavigation
13 */
14class syntax_plugin_docnavigation_toc extends SyntaxPlugin
15{
16
17    /**
18     * Syntax Type
19     *
20     * Needs to return one of the mode types defined in $PARSER_MODES in parser.php
21     *
22     * @return string
23     */
24    public function getType()
25    {
26        return 'substition';
27    }
28
29    /**
30     * Paragraph Type
31     *
32     * Defines how this syntax is handled regarding paragraphs. This is important
33     * for correct XHTML nesting. Should return one of the following:
34     *
35     * 'normal' - The plugin can be used inside paragraphs
36     * 'block'  - Open paragraphs need to be closed before plugin output
37     * 'stack'  - Special case. Plugin wraps other paragraphs.
38     *
39     * @return string
40     * @see Doku_Handler_Block
41     *
42     */
43    public function getPType()
44    {
45        return 'block';
46    }
47
48    /**
49     * Sort for applying this mode
50     *
51     * @return int
52     */
53    public function getSort()
54    {
55        return 150;
56    }
57
58    /**
59     * @param string $mode
60     */
61    public function connectTo($mode)
62    {
63        $this->Lexer->addSpecialPattern('<doctoc\b.*?>', $mode, 'plugin_docnavigation_toc');
64    }
65
66    /**
67     * Handler to prepare matched data for the rendering process
68     *
69     * @param string $match The text matched by the patterns
70     * @param int $state The lexer state for the match
71     * @param int $pos The character position of the matched text
72     * @param Doku_Handler $handler The Doku_Handler object
73     * @return  array Return an array with all data you want to use in render, false don't add an instruction
74     */
75    public function handle($match, $state, $pos, Doku_Handler $handler)
76    {
77        global $ID;
78
79        $optstrs = substr($match, 7, -1); // remove "<doctoc"  and ">"
80        $optstrs = explode(',', $optstrs);
81        $options = [
82            'start' => $ID,
83            'includeheadings' => false,
84            'numbers' => false,
85            'useheading' => useHeading('navigation'),
86            'hidepagelink' => false
87        ];
88        foreach ($optstrs as $optstr) {
89            list($key, $value) = array_pad(explode('=', $optstr, 2), 2, '');
90            $value = trim($value);
91
92            switch (trim($key)) {
93                case 'start':
94                    $options['start'] = $this->getFullPageid($value);
95                    break;
96                case 'includeheadings':
97                    [$start, $end] = array_pad(explode('-', $value, 2), 2, '');
98                    $start = (int)$start;
99                    $end = (int)$end;
100
101                    if ($start < 1) {
102                        $start = 2;
103                    }
104
105                    if ($end < 1) {
106                        $end = $start;
107                    }
108
109                    //order from low to high
110                    if ($start > $end) {
111                        $level = $end;
112                        $end = $start;
113                        $start = $level;
114                    }
115                    $options['includeheadings'] = [$start, $end];
116                    break;
117                case 'numbers':
118                    $options['numbers'] = !empty($value);
119                    break;
120                case 'useheading':
121                    $options['useheading'] = !empty($value);
122                    break;
123                case 'hidepagelink':
124                    $options['hidepagelink'] = !empty($value);
125                    break;
126            }
127        }
128        if ($options['hidepagelink'] && $options['includeheadings'] === false) {
129            $options['includeheadings'] = [1, 2];
130        }
131        return $options;
132    }
133
134    /**
135     * Handles the actual output creation.
136     *
137     * @param string $format output format being rendered
138     * @param Doku_Renderer $renderer the current renderer object
139     * @param array $options data created by handler()
140     * @return  boolean                 rendered correctly? (however, returned value is not used at the moment)
141     */
142    public function render($format, Doku_Renderer $renderer, $options)
143    {
144        global $ID;
145        global $ACT;
146
147        if ($format != 'xhtml') return false;
148        /** @var Doku_Renderer_xhtml $renderer */
149
150        $renderer->nocache();
151
152        $list = [];
153        $recursioncheck = []; //needed for 'hidepagelink' option
154        $pageid = $options['start'];
155        $previouspage = null;
156        while ($pageid !== null) {
157            $pageitem = [];
158            $pageitem['id'] = $pageid;
159            $pageitem['ns'] = getNS($pageitem['id']);
160            $pageitem['type'] = $options['includeheadings'] === false ? 'pageonly' : 'pagewithheadings'; //page or heading
161            $pageitem['level'] = 1;
162            $pageitem['ordered'] = $options['numbers'];
163
164            if ($options['useheading']) {
165                $pageitem['title'] = p_get_first_heading($pageitem['id'], METADATA_DONT_RENDER);
166            } else {
167                $pageitem['title'] = null;
168            }
169            $pageitem['perm'] = auth_quickaclcheck($pageitem['id']);
170
171            if ($pageitem['perm'] >= AUTH_READ) {
172
173                if ($options['hidepagelink']) {
174                    $tocitemlevel = 1;
175                    //recursive check needs a list of added pages
176                    $recursioncheck[$pageid] = true;
177                } else {
178                    //add page to list
179                    $list[$pageid] = $pageitem;
180                    $tocitemlevel = 2;
181                }
182
183                if (!empty($options['includeheadings'])) {
184                    $toc = p_get_metadata($pageid, 'description tableofcontents', METADATA_RENDER_USING_CACHE | METADATA_RENDER_UNLIMITED);
185
186                    $first = true;
187                    if (is_array($toc)) foreach ($toc as $tocitem) {
188                        if ($tocitem['level'] < $options['includeheadings'][0] || $tocitem['level'] > $options['includeheadings'][1]) {
189                            continue;
190                        }
191                        $item = [];
192                        $item['id'] = $pageid . '#' . $tocitem['hid'];
193                        $item['ns'] = getNS($item['id']);
194                        if ($options['hidepagelink'] && $first) {
195                            //mark only first heading(=title), if no pages are shown
196                            $item['type'] = 'firstheading';
197                            $first = false;
198                        } else {
199                            $item['type'] = 'heading';
200                        }
201
202                        $item['level'] = $tocitemlevel + $tocitem['level'] - $options['includeheadings'][0];
203                        $item['title'] = $tocitem['title'];
204
205                        $list[$item['id']] = $item;
206                    }
207                }
208            }
209
210            $pagedata = null;
211            if ($ACT == 'preview' && $pageid === $ID) {
212                // the RENDERER_CONTENT_POSTPROCESS event is triggered just after rendering the instruction,
213                // so syntax instance will exists
214                $pagenav = plugin_load('syntax', 'docnavigation_pagenav');
215                if ($pagenav instanceof syntax_plugin_docnavigation_pagenav) {
216                    $pagedata = $pagenav->getPageData($pageid);
217                }
218            } else {
219                //return null if no metadata
220                $pagedata = p_get_metadata($pageid, 'docnavigation');
221            }
222
223            //check referer
224            if (empty($pagedata['previous']['link']) || $pagedata['previous']['link'] != $previouspage) {
225
226                // is not first page or non-existing page (so without syntax)?
227                if ($previouspage !== null && page_exists($pageid)) {
228                    msg(sprintf($this->getLang('dontlinkback'), $pageid, $previouspage), -1);
229                }
230            }
231
232            $previouspage = $pageid;
233            if (empty($pagedata['next']['link'])) {
234                $pageid = null;
235            } else{
236                $nextpageid = $pagedata['next']['link'];
237                if ($options['hidepagelink'] ? isset($recursioncheck[$nextpageid]) : isset($list[$nextpageid])) {
238                    msg(sprintf($this->getLang('recursionprevented'), $pageid, $nextpageid), -1);
239                    $pageid = null;
240                } else {
241                    $pageid = $nextpageid;
242                }
243            }
244        }
245
246        $renderer->doc .= html_buildlist($list, 'pagnavtoc', [$this, 'listItemNavtoc']);
247
248        return true;
249    }
250
251    /**
252     * Index item formatter
253     *
254     * User function for html_buildlist()
255     *
256     * @param array $item
257     * @return string
258     * @author Andreas Gohr <andi@splitbrain.org>
259     *
260     */
261    public function listItemNavtoc($item)
262    {
263        // default is noNSorNS($id), but we want noNS($id) when useheading is off FS#2605
264        if ($item['title'] === null) {
265            $name = noNS($item['id']);
266        } else {
267            $name = $item['title'];
268        }
269
270        $ret = '';
271        $link = html_wikilink(':' . $item['id'], $name);
272        if ($item['type'] == 'pagewithheadings' || $item['type'] == 'firstheading') {
273            $ret .= '<strong>';
274            $ret .= $link;
275            $ret .= '</strong>';
276        } else {
277            $ret .= $link;
278        }
279        return $ret;
280    }
281
282    /**
283     * Resolves given id against current page to full pageid, removes hash
284     *
285     * @param string $pageid
286     * @return mixed
287     */
288    public function getFullPageid($pageid)
289    {
290        global $ID;
291        // Igor and later
292        if (class_exists('dokuwiki\File\PageResolver')) {
293            $resolver = new dokuwiki\File\PageResolver($ID);
294            $pageid = $resolver->resolveId($pageid);
295        } else {
296            // Compatibility with older releases
297            resolve_pageid(getNS($ID), $pageid, $exists);
298        }
299        [$page, /* $hash */] = array_pad(explode('#', $pageid, 2), 2, '');
300        return $page;
301    }
302
303}
304