xref: /plugin/siteexport/syntax/toc.php (revision ee579aefb08586eb87e57526261b3d377f5772f7)
1<?php
2/**
3 * Search with Scopes
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     i-net software <tools@inetsoftware.de>
7 * @author     Gerry Weissbach <gweissbach@inetsoftware.de>
8 */
9
10// must be run within Dokuwiki
11if (!defined('DOKU_INC')) die();
12if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
13
14require_once(DOKU_PLUGIN . 'syntax.php');
15
16class syntax_plugin_siteexport_toc extends DokuWiki_Syntax_Plugin {
17
18    private $insideToc = false;
19    private $savedToc = array();
20    private $options = array();
21
22    private $mergedPages = array();
23    private $includedPages = array();
24    private $merghintIds = array();
25
26    function getType() { return 'protected'; }
27    function getPType() { return 'block'; }
28    function getAllowedTypes() { return array('container'); }
29    function getSort() { return 100; }
30
31    /**
32     * Connect pattern to lexer
33     */
34    function connectTo($mode) {
35        $this->Lexer->addEntryPattern('<toc>(?=.*?</toc>)', $mode, 'plugin_siteexport_toc');
36        $this->Lexer->addEntryPattern('<toc .+?>(?=.*?</toc>)', $mode, 'plugin_siteexport_toc');
37        $this->Lexer->addSpecialPattern("\[\[.+?\]\]", $mode, 'plugin_siteexport_toc');
38    }
39
40    function postConnect() {
41        $this->Lexer->addExitPattern('</toc.*?>', 'plugin_siteexport_toc');
42    }
43
44    function handle($match, $state, $pos, Doku_Handler $handler) {
45        global $ID, $INFO;
46
47        switch ($state) {
48            case DOKU_LEXER_ENTER:
49
50                $this->insideToc = true;
51                $this->options = explode(' ', substr($match, 5, -1));
52                return array('start' => true, 'pos' => $pos, 'options' => $this->options);
53
54            case DOKU_LEXER_SPECIAL:
55
56                if ($this->insideToc) {
57
58                    $link = preg_replace(array('/^\[\[/', '/\]\]$/u'), '', $match);
59                    // Split title from URL
60                    $link = explode('|', $link, 2);
61                    if (!isset($link[1])) {
62                        $link[1] = NULL;
63                    } else if (preg_match('/^\{\{[^\}]+\}\}$/', $link[1])) {
64                        // If the title is an image, convert it to an array containing the image details
65                        $link[1] = Doku_Handler_Parse_Media($link[1]);
66                    }
67                    $link[0] = trim($link[0]);
68
69                    if (!(preg_match('/^[a-zA-Z0-9\.]+>{1}.*$/u', $link[0]) ||
70                    preg_match('/^\\\\\\\\[\w.:?\-;,]+?\\\\/u', $link[0]) ||
71                    preg_match('#^([a-z0-9\-\.+]+?)://#i', $link[0]) ||
72                    preg_match('<' . PREG_PATTERN_VALID_EMAIL . '>', $link[0]) ||
73                    preg_match('!^#.+!', $link[0]))
74                    ) {
75
76                        // Get current depth from call stack
77                        $depth = 1;
78                        if ($handler->CallWriter instanceof Doku_Handler_List) {
79
80                            $calls = array_reverse($handler->CallWriter->calls);
81                            $call = $calls[0];
82                            foreach ($calls as $item) {
83                                if (in_array($item[0], array('list_item', 'list_open'))) { $call = $item; break; }
84                            }
85
86                            $depth = $handler->CallWriter->interpretSyntax($call[1][0], $listType = null)-1; // Minus one because of plus one inside the interpret function
87                        }
88
89                        if (empty($link[0])) { break; } // No empty elements. This would lead to problems
90                        return array($link[0], $link[1], $depth);
91                    } else {
92                        // use parser! - but with another p
93                        $handler->internallink($match, $state, $pos);
94                    }
95                } else {
96                    // use parser!
97                    $handler->internallink($match, $state, $pos);
98                }
99
100                return false;
101            case DOKU_LEXER_UNMATCHED:
102
103                $handler->_addCall('cdata', array($match), $pos);
104
105                return false;
106            case DOKU_LEXER_EXIT:
107
108                $this->insideToc = false;
109                return 'save__meta';
110        }
111        return false;
112    }
113
114    function render($mode, Doku_Renderer $renderer, $data) {
115        global $ID, $lang, $INFO;
116
117        list($SID, $NAME, $DEPTH) = $data;
118
119        resolve_pageid(getNS($ID), $SID, $exists = null);
120//        $SID = cleanID($SID); // hier kein cleanID, da sonst moeglicherweise der anker verloren geht
121
122        //    Render XHTML and ODT
123        if ($mode == 'xhtml' || $mode == 'odt') {
124
125            // TOC Title
126            if (is_array($data) && $data['start'] == true) {
127
128                if (is_Array($data['options'])) {
129                    foreach ($data['options'] as $opt) {
130                        switch ($opt) {
131                            case 'description' : $renderer->meta['sitetoc']['showDescription'] = true; break;
132                            case 'notoc' : $renderer->meta['sitetoc']['noTOC'] = true; break;
133                            case 'merge' : $renderer->meta['sitetoc']['mergeDoc'] = true; break;
134                            case 'nohead' : $renderer->meta['sitetoc']['noTocHeader'] = true; break;
135                            case 'mergeheader' : $renderer->meta['sitetoc']['mergeHeader'] = true; break;
136                            case 'pagebreak' : $renderer->meta['sitetoc']['pagebreak'] = true; break;
137                            case 'mergehint' : $renderer->meta['sitetoc']['mergehint'] = true; break;
138                        }
139                    }
140                }
141
142                $renderer->section_open("1 sitetoc");
143                if ($renderer->meta['sitetoc']['noTocHeader'] === false) {
144                    $renderer->header($lang['toc'], 1, $data['pos']);
145                }
146
147                return true;
148            } else
149
150            // All Output has been done
151            if (!is_array($data) && $data == 'save__meta') {
152
153                // Close TOC
154                $renderer->section_close();
155
156                if ($renderer->meta['sitetoc']['noTOC'] === true) {
157                    $renderer->doc = preg_replace("/<div.*?sitetoc.*?$/si", "", $renderer->doc);
158                }
159
160                // If this is not set, we may have it as Metadata
161                if ( empty( $this->mergedPages ) && $renderer->meta['sitetoc']['mergeDoc']) {
162                    $toc = $renderer->meta['sitetoc']['siteexportTOC'];
163
164                    if (is_array($toc)) {
165                        foreach ($toc as $tocItem) {
166                            $this->mergedPages[] = array($tocItem['id'], $tocItem['depth']);
167                        }
168                    }
169
170                }
171
172                // If there is some data to be merged
173                if (count($this->mergedPages) > 0) {
174
175                    $renderer->doc = ''; // Start fresh!
176
177                    $renderer->section_open("1 mergedsite" . ($renderer->meta['sitetoc']['mergehint']?' mergehint':''));
178
179                    // Prepare lookup Array
180                    foreach ($this->mergedPages as $tocItem) {
181                        list($this->includedPages[]) = explode('#', $tocItem[0]);
182                    }
183
184                    // Load the instructions
185                    $instr = array();
186                    foreach ($this->mergedPages as $tocElement) {
187
188                        list($tocItem, $depth) = $tocElement;
189                        $file = wikiFN($tocItem);
190
191                        if (@file_exists($file)) {
192                            $instructions = p_cached_instructions($file, false, $tocItem);
193                        } else {
194                            $instructions = p_get_instructions(io_readWikiPage($file, $tocItem));
195                        }
196
197                        // Convert Link and header instructions
198                        $instructions = $this->_convertInstructions($instructions, $addID = null, $renderer, $depth);
199
200                        if ($renderer->meta['sitetoc']['mergeHeader'] && count($this->mergedPages) > 1 ) {
201                            // get a hint for merged pages
202                            if ($renderer->meta['sitetoc']['mergehint']) {
203                                // only if the first section is already there
204                                $mergeHint = p_get_metadata( $tocItem, 'mergehint', METADATA_RENDER_USING_SIMPLE_CACHE );
205                                if ( empty( $mergeHint) ) { $mergeHint = p_get_metadata( $tocItem, 'thema', METADATA_RENDER_USING_SIMPLE_CACHE ); }
206                                if ( empty( $mergeHint) ) { $mergeHint = tpl_pagetitle( $tocItem, true ); }
207                                $instructions = $this->_mergeWithHeaders( $this->_initialHeaderStructure( $instructions ), $instructions, 1, $mergeHint);
208                            }
209                            // Merge
210                            $instr = $this->_mergeWithHeaders( $instr, $instructions, 1);
211                        } else
212                        if ($renderer->meta['sitetoc']['pagebreak']) {
213                            $sitepagebreak = array( array(
214                                'plugin',
215                                array(
216                                    'siteexport_toctools',
217                                    array(
218                                        'pagebreak',
219                                        null,
220                                        null
221                                    )
222                                )
223                            ));
224                            $instr = array_merge($instr, $instructions, $sitepagebreak);
225                        } else {
226                            // Concat
227                            $instr = array_merge($instr, $instructions);
228                        }
229                    }
230
231                    if (!empty($instr)) {
232                        $this->_cleanAllInstructions($instr);
233
234                        //if its the document start, cut off the first element(document information)
235                        if ($instr[count($instr)-1][1][0] == 'siteexport_toctools') {
236                            $instr = array_slice($instr, 0, -1);
237                        }
238
239
240                        // print "<pre>"; print_r($instr); print "</pre>";
241                        $this->_render_output($renderer, $mode, $instr);
242                    }
243
244                    $renderer->section_close();
245                }
246                return true;
247            }
248
249            // Save the current ID
250            $LNID = $SID;
251
252            // Add ID to flags['mergeDoc']
253            if ($renderer->meta['sitetoc']['mergeDoc'] === true) { // || (count($renderer->meta['sitetoc']['siteexportTOC']) > 0 && $renderer->meta['sitetoc']['siteexportMergeDoc'] === true) ) {
254                $this->mergedPages[] = array($SID, $DEPTH);
255                // $default = $renderer->_simpleTitle($SID); $isImage = false;
256                resolve_pageid(getNS($ID), $SID, $exists);
257
258                // $NAME = empty($NAME) ? p_get_first_heading($SID, true) : $NAME;
259                // $LNID = "$ID#" . sectionID($SID, $check);
260
261            } else {
262                // // print normal internal link (XHTML odt)
263                $renderer->internallink($LNID, $NAME, null);
264
265                // Display Description underneath
266                if ($renderer->meta['sitetoc']['showDescription'] === true) {
267                    // $renderer->p_open();
268                    $renderer->cdata(p_get_metadata($SID, 'description abstract', true));
269                    // $renderer->p_close();
270                }
271            }
272
273            // Render Metadata
274        } else if ($mode == 'metadata') {
275            if (!is_array($data) && $data == 'save__meta') {
276                $renderer->meta['sitetoc']['siteexportTOC'] = $this->savedToc;
277
278                foreach ($this->savedToc as $page) {
279                    $renderer->meta['relation']['references'][$page['id']] = $page['exists'];
280                }
281
282                $this->savedToc = array();
283            } else if (!isset($data['start']) && !isset($data['pos'])) {
284                $this->savedToc[] = $this->__addTocItem($SID, $NAME, $DEPTH, $renderer);
285            }
286        } else {
287            return false;
288        }
289
290        return true;
291    }
292
293    /*
294     * pull apart the ID and create an Entry for the TOC
295     */
296    private function __addTocItem($id, $name, $depth, $renderer) {
297        global $conf;
298        global $ID;
299
300        // Render Title
301        $default = $renderer->_simpleTitle($id);
302        $exists = false; $isImage = false; $linktype = null;
303        resolve_pageid(getNS($ID), $id, $exists);
304        $name = $renderer->_getLinkTitle($name, $default, $isImage, $id, $linktype);
305
306        //keep hash anchor
307        list($id, $hash) = explode('#', $id, 2);
308        if (!empty($hash)) $hash = $renderer->_headerToLink($hash);
309
310        // Build Sitetoc Item
311        $item = array();
312        $item['id'] = $id;
313        $item['name'] = $name;
314        $item['anchor'] = $hash;
315        $item['depth'] = $depth;
316        $item['exists'] = $exists;
317        if (!$conf['skipacl'] && auth_quickaclcheck($item['id']) < AUTH_READ) {
318            return false;
319        }
320
321        return $item;
322    }
323
324    /*
325     * Render the output of one page
326     */
327    private function _render_output($renderer, $mode, $instr) {
328        global $ID;
329
330        // Section IDs
331        // $addID = sectionID($addID, $check);    //not possible to use a:b:c for id
332
333        if ($mode == 'xhtml') {
334
335            //--------RENDER
336            //renderer information(TOC build / Cache used)
337            $info = array();
338            $content = p_render($mode, $instr, $info);
339
340            //Remove TOC`s, section edit buttons and tags
341            $content = $this->_cleanXHTML($content);
342
343            // embed the included page
344            // $renderer->doc .= '<div class="include">';
345            //add an anchor to find start of a inserted page
346            // $renderer->doc .= "<a name='$addID' id='$addID'>";
347            $renderer->doc .= $content;
348            // $renderer->doc .= '</div>';
349        } else if ($mode == 'odt') {
350
351            // Loop through the instructions
352            foreach ($instr as $instruction) {
353                // Execute the callback against the Renderer
354                call_user_func_array(array($renderer, $instruction[0]), $instruction[1]);
355            }
356        }
357    }
358
359    /*
360     * Corrects relative internal links and media and
361     * converts headers of included pages to subheaders of the current page
362     */
363    private function _convertInstructions($instr, $id, &$renderer, $depth = 1) {
364        global $ID;
365        global $conf;
366
367        $n = count($instr);
368
369        for ($i = 0; $i < $n; $i++) {
370            //internal links(links inside this wiki) an relative links
371            if ((substr($instr[$i][0], 0, 12) == 'internallink')) {
372                $this->_convert_link($renderer, $instr[$i], $id);
373            }
374            else if ((substr($instr[$i][0], 0, 13) == 'internalmedia')) {
375                $this->_convert_media($renderer, $instr[$i], $id);
376            }
377            else if ((substr($instr[$i][0], 0, 6) == 'header')) {
378                $this->_convert_header($renderer, $instr[$i], $depth-1); // -1 because the depth starts at 1
379            }
380            else if ((substr($instr[$i][0], 0, 12) == 'section_open')) {
381                $this->_convert_section($renderer, $instr[$i], $depth-1); // -1 because the depth starts at 1
382            }
383        }
384
385        //if its the document start, cut off the first element(document information)
386        if ($instr[0][0] == 'document_start')
387        return array_slice($instr, 1, -1);
388        else
389        return $instr;
390    }
391
392    /*
393     * Convert link of given instruction
394     */
395    private function _convert_link(&$renderer, &$instr, $id) {
396        global $ID;
397
398        $exists = false;
399
400        resolve_pageid(getNS($id), $instr[1][0], $exists);
401        list($pageID, $pageReference) = explode("#", $instr[1][0], 2);
402
403        if (in_array($pageID, $this->includedPages)) {
404            // Crate new internal Links
405            $check = null;
406
407            // Either get existing reference or create from first heading. If still not there take the alternate ID
408            $pageNameLink = empty($pageReference) ? sectionID($pageID, $check) : $pageReference;
409
410            $instr[1][0] = $ID . "#" . $pageNameLink;
411
412        } else {
413            // Convert external Links to plain Text
414
415            $instr = array(
416                        "cdata",
417            array($instr[1][1]),
418            $instr[2]
419            );
420        }
421    }
422
423    /*
424     * Convert internalmedia of given instruction
425     */
426    private function _convert_media(&$renderer, &$instr, $id) {
427        global $ID;
428
429        // Resolvemedia returns the absolute path to media by reference
430        $exists = false;
431        resolve_mediaid(getNS($id), $instr[1][0], $exists);
432    }
433
434    /**
435     * @param integer $depth
436     */
437    private function _convert_header(&$renderer, &$instr, $depth) {
438        // More Depth!
439        $instr[1][1] += $depth;
440    }
441
442    /**
443     * @param integer $depth
444     */
445    private function _convert_section(&$renderer, &$instr, $depth) {
446        // More Depth!
447        $instr[1][0] += $depth;
448    }
449
450    private function _mergeWithHeaders($existing, $newInstructions, $level = 1, $mergeHint = array() ) {
451
452        $returnInstructions = array();
453        $preparedInstructions = array();
454        $existingStart = $existingEnd = 0;
455        $firstRun = true;
456
457        while ($this->_findNextHeaderSection($existing, $level, $existingStart, $existingEnd)) {
458
459            if ($firstRun) {
460                $returnInstructions = array_merge($returnInstructions, array_slice($existing, 0, $existingStart));
461                $firstRun = false;
462            }
463
464            $currentSlice = array_slice($existing, $existingStart, $existingEnd-$existingStart);
465
466            // Find matching part with headername
467            $newStart = $newEnd = 0;
468            if ($this->_findNextHeaderSection($newInstructions, $level, $newStart, $newEnd, $currentSlice[0][1][0])) {
469
470                $newSlice = array_slice($newInstructions, $newStart, $newEnd-$newStart);
471                if ($newSlice[0][0] == 'header')
472                    array_shift($newSlice); // Remove Heading
473
474                // merge found parts on next level.
475                $returnedInstructions = $this->_mergeWithHeaders($currentSlice, $newSlice, $level+1, $mergeHint);
476
477                // Put them at the end!
478                $preparedInstructions = array_merge($preparedInstructions, $returnedInstructions);
479
480                // Remove from input
481                array_splice($newInstructions, $newStart, $newEnd-$newStart);
482            } else {
483                // Nothing else found
484                $preparedInstructions = array_merge($preparedInstructions, $currentSlice);
485            }
486
487            $existingStart = $existingEnd;
488        }
489
490        // Append the rest
491        $returnInstructions = array_merge($returnInstructions, array_slice($existing, $existingStart));
492
493        // Check for section close inconsistencies and put one at the very end ...
494        $section_postpend = array();
495        if (
496            (
497                ($tmp1 = array_slice($newInstructions, -1))
498                && ($tmp1[0][0] == 'section_close')
499            )
500            &&
501            (
502                ($tmp2 = array_slice($newInstructions, -2))
503                && ($tmp2[0][0] == 'section_close')
504            )
505        ) {
506            $section_postpend = array_splice($newInstructions, -1);
507        }
508        if (
509            (
510                ($tmp3 = array_slice($returnInstructions, -1))
511                && ($tmp3[0][0] == 'section_close')
512            )
513            &&
514            (
515                ($tmp4 = array_slice($returnInstructions, -2))
516                && ($tmp4[0][0] == 'section_close')
517            )
518        ) {
519            $section_postpend = array_merge($section_postpend, array_splice($returnInstructions, -1));
520        }
521
522        // What if there are headings left inside the $newInstructions?????
523        // Find matching part with headername
524        $newStart = $newEnd = 0;
525        $section_prepend = array();
526        if ($this->_findNextHeaderSection($newInstructions, $level, $newStart, $newEnd)) {
527            // If there are header in here, build a prepend and have the rest at the end
528            $section_prepend = array_splice($newInstructions, 0, $newStart);
529        } else {
530            // If not, prepend all of it.
531            $section_prepend = $newInstructions;
532            $newInstructions = array();
533        }
534
535        $this->_insertMergeHint( $section_prepend, $mergeHint );
536
537        $returnInstructions = array_merge($returnInstructions, $section_prepend, $preparedInstructions, $newInstructions, $section_postpend);
538
539        return $returnInstructions;
540    }
541
542    /**
543     * @param integer $level
544     */
545    private function _findNextHeaderSection($section, $level, &$start, &$end, $headerName = null) {
546
547        $inCount = count($section);
548        $currentSlice = -1;
549
550        // Find Level 1 Header that matches.
551        for ($i = $start; $i < $inCount; $i++) {
552
553            $instruction = $section[$i];
554            $end = $i; // Or it will be lost and a section close will be missing.
555
556            // First Level Header
557            if ($instruction[0] == 'header' && $instruction[1][1] == $level) {
558
559                if ($currentSlice > 0) {
560                    return true;
561                }
562
563                if ($headerName == null || ($headerName == $instruction[1][0])) {
564                    // Begin of new slice ...
565                    $start = $currentSlice = $i;
566                }
567            }
568        }
569
570        // Nothing found
571        $end = $i; // Or it will be lost and a section close will be missing.
572        return $currentSlice > 0;
573    }
574
575    private function _cleanAllInstructions(&$instr) {
576        $this->_cleanInstructions($instr, '/section_(close|open)/');
577        $this->_cleanInstructions($instr, '/listu_(close|open)/');
578        $this->_cleanInstructions($instr, '/listo_(close|open)/');
579    }
580
581    /**
582     * @param string $tag
583     */
584    private function _cleanInstructions(&$instructions, $tag) {
585
586        $inCount = count($instructions);
587        for ($i = 0; $i < $inCount; $i++) {
588
589            // Last instruction
590            if ($i == $inCount-1) {
591                break;
592            }
593
594            if (preg_match($tag, $instructions[$i][0]) && preg_match($tag, $instructions[$i+1][0]) && $instructions[$i][0] != $instructions[$i+1][0]) {
595
596                // found different tags, but both match the expression and follow each other - so they can be elliminated
597                array_splice($instructions, $i, 2);
598                $inCount -= 2;
599                $i--;
600            }
601
602        }
603    }
604
605    /**
606     * Strip everything except for the headers
607     */
608    private function _initialHeaderStructure($instructions) {
609        $inCount = count($instructions);
610        for ($i = 0; $i < $inCount; $i++) {
611
612            // Last instruction
613            if ($i == $inCount-1) {
614                break;
615            }
616
617            if (!in_array($instructions[$i][0], array('header', 'section_open', 'section_close', 'p_open', 'p_close'))) {
618                // found non-matching
619                array_splice($instructions, $i, 1);
620                $inCount --;
621                $i--;
622            }
623        }
624        return $instructions;
625    }
626
627    private function _insertMergeHint( &$instructions, $mergeHint ) {
628
629        // Surround new slice with a mergehint
630        if ( empty( $mergeHint ) ) { return; }
631
632        // No emtpy insruction sets.
633        $this->_cleanAllInstructions( $instructions );
634        if ( empty( $instructions ) ) { return; }
635
636        // only section content should be surrounded.
637        if ( $instructions[0][0] != 'section_open' ) { return; }
638
639        // save for later use
640        $mergeHints = array();
641        $mergeHintId = sectionid( $mergeHint, $mergeHints );
642        $this->merghintIds[$mergeHintId] = $mergeHint;
643
644        $mergeHintPrepend = array( array(
645            'plugin',
646            array(
647                'siteexport_toctools',
648                array(
649                    'mergehint',
650                    'start',
651                    $mergeHint,
652                    $mergeHintId
653                )
654            )
655        ));
656
657        $mergeHintPostpend = array( array(
658            'plugin',
659            array(
660                'siteexport_toctools',
661                array(
662                    'mergehint',
663                    'end',
664                    $mergeHint
665                )
666            )
667        ));
668
669/*
670        print "\n\n#########\n";
671        print_r($instructions);
672        print "\nn#########\n\n";
673*/
674        $instructions = array_merge( $mergeHintPrepend, $instructions, $mergeHintPostpend );
675    }
676
677    /**
678     * Remove TOC, section edit buttons and tags
679     */
680    function _cleanXHTML($xhtml) {
681        $replace = array(
682            '!<div class="toc">.*?(</div>\n</div>)!s' => '', // remove TOCs
683            '#<!-- SECTION \[(\d*-\d*)\] -->#s'       => '', // remove section edit buttons
684            '!<div id="tags">.*?(</div>)!s'           => ''  // remove category tags
685        );
686        $xhtml = preg_replace(array_keys($replace), array_values($replace), $xhtml);
687        return $xhtml;
688    }
689
690    /**
691     * Allow the plugin to prevent DokuWiki creating a second instance of itself
692     *
693     * @return bool   true if the plugin can not be instantiated more than once
694     */
695    function isSingleton() {
696        return true;
697    }
698}
699// vim:ts=4:sw=4:et:enc=utf-8:
700