1<?php
2
3/**
4 * Indexmenu Action Plugin:   Indexmenu Component.
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Samuele Tognini <samuele@samuele.netsons.org>
8 */
9
10use dokuwiki\Extension\ActionPlugin;
11use dokuwiki\Extension\Event;
12use dokuwiki\Extension\EventHandler;
13use dokuwiki\plugin\indexmenu\Search;
14use dokuwiki\Ui\Index;
15
16/**
17 * Class action_plugin_indexmenu
18 */
19class action_plugin_indexmenu extends ActionPlugin
20{
21    /**
22     * plugin should use this method to register its handlers with the dokuwiki's event controller
23     *
24     * @param EventHandler $controller DokuWiki's event controller object.
25     */
26    public function register(EventHandler $controller)
27    {
28        if ($this->getConf('only_admins')) {
29            $controller->register_hook('IO_WIKIPAGE_WRITE', 'BEFORE', $this, 'removeSyntaxIfNotAdmin');
30        }
31        if ($this->getConf('page_index') != '') {
32            $controller->register_hook('TPL_ACT_RENDER', 'BEFORE', $this, 'loadOwnIndexPage');
33        }
34        $controller->register_hook('DOKUWIKI_STARTED', 'AFTER', $this, 'extendJSINFO');
35        $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeCache');
36        if ($this->getConf('show_sort')) {
37            $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'showSortNumberAtTopOfPage');
38        }
39        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'ajaxCalls');
40        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addStylesForSkins');
41    }
42
43    /**
44     * Check if user has permission to insert indexmenu
45     *
46     * @param Event $event
47     *
48     * @author Samuele Tognini <samuele@samuele.netsons.org>
49     */
50    public function removeSyntaxIfNotAdmin(Event $event)
51    {
52        global $INFO;
53        if (!$INFO['ismanager']) {
54            $event->data[0][1] = preg_replace("/{{indexmenu(|_n)>.+?}}/", "", $event->data[0][1]);
55        }
56    }
57
58    /**
59     * Add additional info to $JSINFO
60     *
61     * @param Event $event
62     *
63     * @author Gerrit Uitslag <klapinklapin@gmail.com>
64     * @author Samuele Tognini <samuele@samuele.netsons.org>
65     */
66    public function extendJSINFO(Event $event)
67    {
68        global $INFO, $JSINFO;
69
70        $JSINFO['isadmin'] = (int)$INFO['isadmin'];
71        $JSINFO['isauth'] = isset($INFO['userinfo']) ? (int) $INFO['userinfo'] : 0;
72    }
73
74    /**
75     * Check for pages changes and eventually purge cache.
76     *
77     * @param Event $event
78     *
79     * @author Samuele Tognini <samuele@samuele.netsons.org>
80     */
81    public function purgeCache(Event $event)
82    {
83        global $ID;
84        global $conf;
85        global $INPUT;
86        global $INFO;
87
88        /** @var cache_parser $cache */
89        $cache = &$event->data;
90
91        if (!isset($cache->page)) return;
92        //purge only xhtml cache
93        if ($cache->mode != "xhtml") return;
94        //Check if it is an indexmenu page
95        if (!p_get_metadata($ID, 'indexmenu hasindexmenu')) return;
96
97        $aclcache = $this->getConf('aclcache');
98        if ($conf['useacl']) {
99            $newkey = false;
100            if ($aclcache == 'user') {
101                //Cache per user
102                if ($INPUT->server->str('REMOTE_USER')) {
103                    $newkey = $INPUT->server->str('REMOTE_USER');
104                }
105            } elseif ($aclcache == 'groups') {
106                //Cache per groups
107                if (isset($INFO['userinfo']['grps'])) {
108                    $newkey = implode('#', $INFO['userinfo']['grps']);
109                }
110            }
111            if ($newkey) {
112                $cache->key .= "#" . $newkey;
113                $cache->cache = getCacheName($cache->key, $cache->ext);
114            }
115        }
116        //Check if a page is more recent than purgefile.
117        if (@filemtime($cache->cache) < @filemtime($conf['cachedir'] . '/purgefile')) {
118            $event->preventDefault();
119            $event->stopPropagation();
120            $event->result = false;
121        }
122    }
123
124    /**
125     * Render a defined page as index.
126     *
127     * @param Event $event
128     *
129     * @author Samuele Tognini <samuele@samuele.netsons.org>
130     */
131    public function loadOwnIndexPage(Event $event)
132    {
133        if ('index' != $event->data) return;
134        if (!file_exists(wikiFN($this->getConf('page_index')))) return;
135
136        global $lang;
137
138        echo '<h1><a id="index">' . $lang['btn_index'] . "</a></h1>\n";
139        echo p_wiki_xhtml($this->getConf('page_index'));
140        $event->preventDefault();
141        $event->stopPropagation();
142    }
143
144    /**
145     * Display the indexmenu sort number.
146     *
147     * @param Event $event
148     *
149     * @author Samuele Tognini <samuele@samuele.netsons.org>
150     */
151    public function showSortNumberAtTopOfPage(Event $event)
152    {
153        global $ID, $ACT, $INFO;
154        if ($INFO['isadmin'] && $ACT == 'show') {
155            if ($n = p_get_metadata($ID, 'indexmenu_n')) {
156                echo '<div class="info">';
157                echo $this->getLang('showsort') . $n;
158                echo '</div>';
159            }
160        }
161    }
162
163    /**
164     * Handles ajax requests for indexmenu
165     *
166     * @param Event $event
167     */
168    public function ajaxCalls(Event $event)
169    {
170        if ($event->data !== 'indexmenu') {
171            return;
172        }
173        //no other ajax call handlers needed
174        $event->stopPropagation();
175        $event->preventDefault();
176
177        global $INPUT;
178        switch ($INPUT->str('req')) {
179            case 'local':
180                //list themes
181                $this->getlocalThemes();
182                break;
183
184            case 'toc':
185                //print toc preview
186                if ($INPUT->has('id')) {
187                    echo $this->printToc($INPUT->str('id'));
188                }
189                break;
190
191            case 'index':
192                //for dTree
193                //retrieval of data of the extra nodes for the indexmenu (if ajax loading set with max#m(#n)
194                if ($INPUT->has('idx')) {
195                    echo $this->printIndex($INPUT->str('idx'));
196                }
197                break;
198
199            case 'fancytree':
200                //data for new index build with Fancytree
201                $this->getDataFancyTree();
202                break;
203        }
204    }
205
206    /**
207     * Handles ajax requests for FancyTree
208     *
209     * @return void
210     */
211    private function getDataFancyTree()
212    {
213        global $INPUT;
214
215        $ns = $INPUT->str('ns', '');
216        $ns = rtrim($ns, ':');
217        //key of directory has extra : on the end
218        $level = -1; //opened levels. -1=all levels open
219        $max = 1; //levels to load by lazyloading. Before the default was 0. CHANGED to 1.
220        $skipFileCombined = [];
221        $skipNsCombined = [];
222
223        if ($INPUT->int('max') > 0) {
224            $max = $INPUT->int('max'); // max#n#m, if init: #n, otherwise #m
225            $level = $max;
226        }
227        if ($INPUT->int('level', -10) >= -1) {
228            $level = $INPUT->int('level');
229        }
230        $isInit = $INPUT->bool('init');
231
232        $currentPage = $INPUT->str('currentpage');
233        if ($isInit) {
234            $subnss = $INPUT->arr('subnss');
235            // if 'navbar' is enabled add current ns to list
236            if ($INPUT->bool('navbar')) {
237                $currentNs = getNS($currentPage);
238                if ($currentNs !== false) {
239                    $subnss[] = [$currentNs, 1];
240                }
241            }
242            // alternative, via javascript.. https://wwwendt.de/tech/fancytree/doc/jsdoc/Fancytree.html#loadKeyPath
243        } else {
244            //not set via javascript at the moment.. ajax opens per level, so subnss has no use here
245            $subnss = $INPUT->str('subnss');
246            if ($subnss !== '') {
247                $subnss = [[cleanID($subnss), 1]];
248            }
249        }
250
251        $skipf = $INPUT->str('skipfile');
252        $skipFileCombined[] = $this->getConf('skip_file');
253        if (!empty($skipf)) {
254            $index = 0;
255            //prefix is '=' or '+'
256            if ($skipf[0] == '+') {
257                $index = 1;
258            }
259            $skipFileCombined[$index] = substr($skipf, 1);
260        }
261        $skipn = $INPUT->str('skipns');
262        $skipNsCombined[] = $this->getConf('skip_index');
263        if (!empty($skipn)) {
264            $index = 0;
265            //prefix is '=' or '+'
266            if ($skipn[0] == '+') {
267                $index = 1;
268            }
269            $skipNsCombined[$index] = substr($skipn, 1);
270        }
271
272        $opts = [
273            //only set for init, lazy requests equal to max
274            'level' => $level,
275            //nons only needed for init as it has no nested nodes
276            'nons' => $INPUT->bool('nons'),
277            'nopg' => $INPUT->bool('nopg'),
278            //init with complex array, empty if lazy loading
279            'subnss' => $subnss,
280            'max' => $max,
281            'skipnscombined' => $skipNsCombined,
282            'skipfilecombined' => $skipFileCombined,
283            'headpage' => $this->getConf('headpage'),
284            'hide_headpage' => $this->getConf('hide_headpage'),
285        ];
286
287        $sort = [
288            'sort' => $INPUT->str('sort'),
289            'msort' => $INPUT->str('msort'),
290            'rsort' => $INPUT->bool('rsort'),
291            'nsort' => $INPUT->bool('nsort'),
292            'hsort' => $INPUT->bool('hsort')
293        ];
294
295        $opts['tempNew'] = true; //TODO temporary for recognizing treenew in the search function
296
297        $search = new Search($sort);
298        $data = $search->search($ns, $opts);
299        $fancytreeData = $search->buildFancytreeData($data, $isInit, $currentPage, $opts['nopg']);
300
301        //add eventually debug info
302        if ($isInit) {
303            //for lazy loading are other items than children not supported.
304//            $fancytreeData['opts'] = $opts;
305//            $fancytreeData['sort'] = $sort;
306//            $fancytreeData['debug'] = $data;
307        } else {
308            //returns only children, therefore, add debug info to first child
309//            $fancytreeData[0]['opts'] = $opts;
310//            $fancytreeData[0]['sort'] = $sort;
311//            $fancytreeData[0]['debug'] = $data;
312        }
313
314        header('Content-Type: application/json');
315        echo json_encode($fancytreeData);
316    }
317
318    /**
319     * Print a list of local themes
320     *
321     * @author Samuele Tognini <samuele@samuele.netsons.org>
322     * @author Gerrit Uitslag <klapinklapin@gmail.com>
323     */
324    private function getlocalThemes()
325    {
326        header('Content-Type: application/json');
327
328        $themebase = 'lib/plugins/indexmenu/images';
329
330        $handle = @opendir(DOKU_INC . $themebase);
331        $themes = [];
332        while (false !== ($file = readdir($handle))) {
333            if (
334                is_dir(DOKU_INC . $themebase . '/' . $file)
335                && $file != "."
336                && $file != ".."
337                && $file != "repository"
338                && $file != "tmp"
339                && $file != ".svn"
340            ) {
341                $themes[] = $file;
342            }
343        }
344        closedir($handle);
345        sort($themes);
346
347        echo json_encode([
348            'themebase' => $themebase,
349            'themes' => $themes
350        ]);
351    }
352
353    /**
354     * Print a toc preview
355     *
356     * @param string $id
357     * @return string
358     *
359     * @author Samuele Tognini <samuele@samuele.netsons.org>
360     * @author Andreas Gohr <andi@splitbrain.org>
361     */
362    private function printToc($id)
363    {
364        $id = cleanID($id);
365        if (auth_quickaclcheck($id) < AUTH_READ) return '';
366
367        $meta = p_get_metadata($id);
368        $toc = $meta['description']['tableofcontents'] ?? [];
369
370        if (count($toc) > 1) {
371            //display ToC of two or more headings
372            $out = $this->renderToc($toc);
373        } else {
374            //display page abstract
375            $out = $this->renderAbstract($id, $meta);
376        }
377        return $out;
378    }
379
380    /**
381     * Return the TOC rendered to XHTML
382     *
383     * @param $toc
384     * @return string
385     *
386     * @author Andreas Gohr <andi@splitbrain.org>
387     * @author Gerrit Uitslag <klapinklapin@gmail.com>
388     */
389    private function renderToc($toc)
390    {
391        global $lang;
392        $out = '<div class="tocheader">';
393        $out .= $lang['toc'];
394        $out .= '</div>';
395        $out .= '<div class="indexmenu_toc_inside">';
396        $out .= html_buildlist($toc, 'toc', [$this, 'formatIndexmenuListTocItem'], null, true);
397        $out .= '</div>';
398        return $out;
399    }
400
401    /**
402     * Return the page abstract rendered to XHTML
403     *
404     * @param $id
405     * @param array $meta by reference
406     * @return string
407     */
408    private function renderAbstract($id, $meta)
409    {
410        $out = '<div class="tocheader">';
411        $out .= '<a href="' . wl($id) . '">';
412        $out .= $meta['title'] ? hsc($meta['title']) : hsc(noNS($id));
413        $out .= '</a>';
414        $out .= '</div>';
415        if ($meta['description']['abstract']) {
416            $out .= '<div class="indexmenu_toc_inside">';
417            $out .= p_render('xhtml', p_get_instructions($meta['description']['abstract']), $info);
418            $out .= '</div></div>';
419        }
420        return $out;
421    }
422
423    /**
424     * Callback for html_buildlist
425     *
426     * @param $item
427     * @return string
428     */
429    public function formatIndexmenuListTocItem($item)
430    {
431        global $INPUT;
432
433        $id = cleanID($INPUT->str('id'));
434
435        if (isset($item['hid'])) {
436            $link = '#' . $item['hid'];
437        } else {
438            $link = $item['link'];
439        }
440
441        //prefix anchers with page id
442        if ($link[0] == '#') {
443            $link = wl($id, $link, false, '');
444        }
445        return '<a href="' . $link . '">' . hsc($item['title']) . '</a>';
446    }
447
448    /**
449     * Print index nodes
450     *
451     * @param $ns
452     * @return string
453     *
454     * @author Rene Hadler <rene.hadler@iteas.at>
455     * @author Samuele Tognini <samuele@samuele.netsons.org>
456     * @author Andreas Gohr <andi@splitbrain.org>
457     */
458    private function printIndex($ns)
459    {
460        global $conf, $INPUT;
461        $idxm = new syntax_plugin_indexmenu_indexmenu();
462        $ns = $idxm->parseNs(rawurldecode($ns));
463        $level = -1;
464        $max = 0;
465        $data = [];
466        $skipfilecombined = [];
467        $skipnscombined = [];
468
469        if ($INPUT->int('max') > 0) {
470            $max = $INPUT->int('max');
471            $level = $max;
472        }
473        $nss = $INPUT->str('nss', '', true);
474        $sort['sort'] = $INPUT->str('sort', '', true);
475        $sort['msort'] = $INPUT->str('msort', '', true);
476        $sort['rsort'] = $INPUT->bool('rsort', false, true);
477        $sort['nsort'] = $INPUT->bool('nsort', false, true);
478        $sort['hsort'] = $INPUT->bool('hsort', false, true);
479        $search = new Search($sort);
480        $fsdir = "/" . utf8_encodeFN(str_replace(':', '/', $ns));
481
482        $skipf = utf8_decodeFN($INPUT->str('skipfile'));
483        $skipfilecombined[] = $this->getConf('skip_file');
484        if (!empty($skipf)) {
485            $index = 0;
486            if ($skipf[0] == '+') {
487                $index = 1;
488            }
489            $skipfilecombined[$index] = substr($skipf, 1);
490        }
491        $skipn = utf8_decodeFN($INPUT->str('skipns'));
492        $skipnscombined[] = $this->getConf('skip_index');
493        if (!empty($skipn)) {
494            $index = 0;
495            if ($skipn[0] == '+') {
496                $index = 1;
497            }
498            $skipnscombined[$index] = substr($skipn, 1);
499        }
500
501        $opts = [
502            'level' => $level,
503            'nons' => $INPUT->bool('nons', false, true),
504            'nss' => [[$nss, 1]],
505            'max' => $max,
506            'js' => false,
507            'nopg' => $INPUT->bool('nopg', false, true),
508            'skipnscombined' => $skipnscombined,
509            'skipfilecombined' => $skipfilecombined,
510            'headpage' => $idxm->getConf('headpage'),
511            'hide_headpage' => $idxm->getConf('hide_headpage')
512        ];
513        if ($sort['sort'] || $sort['msort'] || $sort['rsort'] || $sort['hsort']) {
514            $search->customSearch($data, $conf['datadir'], [$search, 'searchIndexmenuItems'], $opts, $fsdir);
515        } else {
516            search($data, $conf['datadir'], [$search, 'searchIndexmenuItems'], $opts, $fsdir);
517        }
518
519        $out = '';
520        if ($INPUT->int('nojs') === 1) {
521            $idx = new Index();
522            $out_tmp = html_buildlist($data, 'idx', [$idxm, 'formatIndexmenuItem'], [$idx, 'tagListItem']);
523            $out .= preg_replace('/<ul class="idx">(.*)<\/ul>/s', "$1", $out_tmp);
524        } else {
525            $nodes = $idxm->builddTreeNodes($data, '', false);
526            $out = "ajxnodes = [";
527            $out .= rtrim($nodes[0], ",");
528            $out .= "];";
529        }
530        return $out;
531    }
532
533    /**
534     * Add Js & Css after template is displayed
535     *
536     * @param Event $event
537     */
538    public function addStylesForSkins(Event $event)
539    {
540
541//        $event->data["link"][] = [
542//            "type" => "text/css",
543//            "rel" => "stylesheet",
544//            "href" => DOKU_BASE . "lib/plugins/indexmenu/scripts/fancytree/... etc etc"
545//        ];
546
547//        $event->data["link"][] = [
548//            "type" => "text/css",
549//            "rel" => "stylesheet",
550//            "href" => "//fonts.googleapis.com/icon?family=Material+Icons"
551//        ];
552
553//        $event->data["link"][] = [
554//            "type" => "text/css",
555//            "rel" => "stylesheet",
556//            "href" => "//code.getmdl.io/1.3.0/material.indigo-pink.min.css"
557//        ];
558    }
559}
560