1<?php
2
3/**
4 * Plugin QnA: Layout parser
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Mykola Ostrovskyy <dwpforge@gmail.com>
8 */
9
10/* Must be run within Dokuwiki */
11if(!defined('DOKU_INC')) die();
12
13if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
14require_once(DOKU_PLUGIN . 'action.php');
15
16class action_plugin_qna extends DokuWiki_Action_Plugin {
17
18    const STATE_CLOSED   = 0;
19    const STATE_QUESTION = 1;
20    const STATE_ANSWER   = 2;
21
22    private $rewriter;
23    private $blockState;
24    private $headerIndex;
25    private $headerTitle;
26    private $headerLevel;
27    private $headerId;
28    private $headerCheck;
29
30    /**
31     * Register callbacks
32     */
33    public function register(Doku_Event_Handler $controller) {
34        $controller->register_hook('DOKUWIKI_STARTED', 'BEFORE', $this, 'beforeDokuwikiStarted');
35        $controller->register_hook('PARSER_HANDLER_DONE', 'AFTER', $this, 'afterParserHandlerDone');
36        $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'beforeParserCacheUse');
37    }
38
39    /**
40     * Prepare plugin stylesheet file
41     */
42    public function beforeDokuwikiStarted($event) {
43        $fromConf = dirname(__FILE__) . '/style/' . $this->getConf('style') . '.less';
44        $inUse = dirname(__FILE__) . '/all.less';
45        if (!@file_exists($inUse) || @filesize($inUse) != @filesize($fromConf)) {
46            @copy($fromConf, $inUse);
47        }
48    }
49
50    /**
51     *
52     */
53    public function afterParserHandlerDone($event, $param) {
54        $this->reset();
55        $this->fixLayout($event);
56    }
57
58    /**
59     * Reset internal state
60     */
61    private function reset() {
62        $this->rewriter = new qna_instruction_rewriter();
63        $this->blockState = self::STATE_CLOSED;
64        $this->headerIndex = -1;
65        $this->headerTitle = '';
66        $this->headerLevel = 0;
67        $this->headerId = '';
68        $this->headerCheck = array();
69    }
70
71    /**
72     * Insert implicit instructions
73     */
74    private function fixLayout($event) {
75        $instructions = count($event->data->calls);
76        for ($i = 0; $i < $instructions; $i++) {
77            $instruction = $event->data->calls[$i];
78
79            switch ($instruction[0]) {
80                case 'header':
81                    $this->headerIndex = $i;
82                    $this->headerTitle = $instruction[1][0];
83                    $this->headerLevel = $instruction[1][1];
84                    $this->headerId = sectionID($instruction[1][0], $this->headerCheck);
85                    /* Fall through */
86
87                case 'section_close':
88                case 'section_edit':
89                case 'section_open':
90                    if ($this->blockState != self::STATE_CLOSED) {
91                        $this->rewriter->insertBlockCall($i, 'close_block', 2);
92                        $this->blockState = self::STATE_CLOSED;
93                    }
94                    break;
95
96                case 'plugin':
97                    switch ($instruction[1][0]) {
98                        case 'qna_block':
99                            $this->handlePluginQnaBlock($i, $instruction[1][1]);
100                            break;
101
102                        case 'qna_header':
103                            $this->handlePluginQnaHeader($i);
104                            break;
105                    }
106                    break;
107            }
108        }
109
110        if ($this->blockState != self::STATE_CLOSED) {
111            $this->rewriter->appendBlockCall('close_block', 2);
112        }
113
114        $this->rewriter->apply($event->data->calls);
115    }
116
117    /**
118     * Insert implicit instructions
119     */
120    private function handlePluginQnaBlock($index, $data) {
121        switch ($data[0]) {
122            case 'open_question':
123                if ($this->blockState != self::STATE_CLOSED) {
124                    $this->rewriter->insertBlockCall($index, 'close_block', 2);
125                }
126
127                $this->rewriter->insertBlockCall($index, 'open_block');
128                $this->rewriter->setQuestionLevel($index, $this->headerLevel + 1);
129                $this->blockState = self::STATE_QUESTION;
130                break;
131
132            case 'open_answer':
133                switch ($this->blockState) {
134                    case self::STATE_CLOSED:
135                        $this->rewriter->delete($index);
136                        break;
137
138                    case self::STATE_QUESTION:
139                    case self::STATE_ANSWER:
140                        $this->rewriter->insertBlockCall($index, 'close_block');
141                        $this->blockState = self::STATE_ANSWER;
142                        break;
143                }
144                break;
145
146            case 'close_block':
147                switch ($this->blockState) {
148                    case self::STATE_CLOSED:
149                        $this->rewriter->delete($index);
150                        break;
151
152                    case self::STATE_QUESTION:
153                    case self::STATE_ANSWER:
154                        $this->rewriter->insertBlockCall($index, 'close_block');
155                        $this->blockState = self::STATE_CLOSED;
156                        break;
157                }
158                break;
159        }
160    }
161
162    /**
163     * Wrap the last header
164     */
165    private function handlePluginQnaHeader($index) {
166        /* On a clean install the distance between the header instruction and qna_header dummy
167           sould be 2 (one section_open in between). Allowing distance to be in the range from
168           1 to 3 gives some flexibility for better compatibility with other plugins that might
169           rearrange instructions around the header. */
170        if (($index - $this->headerIndex) < 4) {
171            $data[0] ='open';
172            $data[1] = $this->headerTitle;
173            $data[2] = $this->headerId;
174            $data[3] = $this->headerLevel;
175
176            $this->rewriter->insertHeaderCall($this->headerIndex, $data);
177            $this->rewriter->insertHeaderCall($this->headerIndex + 1, 'close');
178        }
179
180        $this->rewriter->delete($index);
181    }
182
183    /**
184     *
185     */
186    public function beforeParserCacheUse($event, $param) {
187        global $ID;
188
189        $cache = $event->data;
190
191        if (isset($cache->page) && ($cache->page == $ID)) {
192            if (isset($cache->mode) && ($cache->mode == 'xhtml')) {
193                $depends = p_get_metadata($ID, 'relation depends');
194
195                if (!empty($depends) && isset($depends['rendering'])) {
196                    $this->addDependencies($cache, array_keys($depends['rendering']));
197                }
198            }
199        }
200    }
201
202    /**
203     * Add extra dependencies to the cache
204     */
205    private function addDependencies($cache, $depends) {
206        foreach ($depends as $file) {
207            if (!in_array($file, $cache->depends['files']) && file_exists($file)) {
208                $cache->depends['files'][] = $file;
209            }
210        }
211    }
212}
213
214class qna_instruction_rewriter {
215
216    const DELETE = 1;
217    const INSERT = 2;
218    const SET_LEVEL = 3;
219
220    private $correction;
221
222    /**
223     * Constructor
224     */
225    public function __construct() {
226        $this->correction = array();
227    }
228
229    /**
230     * Remove instruction at $index
231     */
232    public function delete($index) {
233        $this->correction[$index][] = array(self::DELETE);
234    }
235
236    /**
237     * Insert a plugin call in front of instruction at $index
238     */
239    public function insertPluginCall($index, $name, $data, $state, $text = '') {
240        $this->correction[$index][] = array(self::INSERT, array('plugin', array($name, $data, $state, $text)));
241    }
242
243    /**
244     * Insert qna_block plugin call in front of instruction at $index
245     */
246    public function insertBlockCall($index, $data, $repeat = 1) {
247        for ($i = 0; $i < $repeat; $i++) {
248            $this->insertPluginCall($index, 'qna_block', array($data), DOKU_LEXER_SPECIAL);
249        }
250    }
251
252    /**
253     * Insert qna_header plugin call in front of instruction at $index
254     */
255    public function insertHeaderCall($index, $data) {
256        if (!is_array($data)) {
257            $data = array($data);
258        }
259
260        $this->insertPluginCall($index, 'qna_header', $data, DOKU_LEXER_SPECIAL);
261    }
262
263    /**
264     * Append a plugin call at the end of the instruction list
265     */
266    public function appendPluginCall($name, $data, $state, $text = '') {
267        $this->correction[-1][] = array(self::INSERT, array('plugin', array($name, $data, $state, $text)));
268    }
269
270    /**
271     * Append qna_block plugin call at the end of the instruction list
272     */
273    public function appendBlockCall($data, $repeat = 1) {
274        for ($i = 0; $i < $repeat; $i++) {
275            $this->appendPluginCall('qna_block', array($data), DOKU_LEXER_SPECIAL);
276        }
277    }
278
279    /**
280     * Set open_question list level for TOC
281     */
282    public function setQuestionLevel($index, $level) {
283        $this->correction[$index][] = array(self::SET_LEVEL, $level);
284    }
285
286    /**
287     * Apply the corrections
288     */
289    public function apply(&$instruction) {
290        if (count($this->correction) > 0) {
291            $index = $this->getCorrectionIndex();
292            $corrections = count($index);
293            $instructions = count($instruction);
294            $output = array();
295
296            for ($c = 0, $i = 0; $c < $corrections; $c++, $i++) {
297                /* Copy all instructions, which are ahead of the next correction */
298                for ( ; $i < $index[$c]; $i++) {
299                    $output[] = $instruction[$i];
300                }
301
302                $this->applyCorrections($i, $instruction, $output);
303            }
304
305            /* Copy the rest of instructions after the last correction */
306            for ( ; $i < $instructions; $i++) {
307                $output[] = $instruction[$i];
308            }
309
310            /* Handle appends */
311            if (array_key_exists(-1, $this->correction)) {
312                $this->applyAppend($output);
313            }
314
315            $instruction = $output;
316        }
317    }
318
319    /**
320     * Sort corrections on instruction index, remove appends
321     */
322    private function getCorrectionIndex() {
323        $result = array_keys($this->correction);
324        asort($result);
325        $result = array_values($result);
326
327        /* Remove appends */
328        if ($result[0] == -1) {
329            array_shift($result);
330        }
331
332        return $result;
333    }
334
335    /**
336     * Apply corrections at $index
337     */
338    private function applyCorrections($index, $input, &$output) {
339        $delete = false;
340        $position = $input[$index][2];
341
342        foreach ($this->correction[$index] as $correction) {
343            switch ($correction[0]) {
344                case self::DELETE:
345                    $delete = true;
346                    break;
347
348                case self::INSERT:
349                    $output[] = array($correction[1][0], $correction[1][1], $position);
350                    break;
351
352                case self::SET_LEVEL:
353                    if (($input[$index][0] == 'plugin') && ($input[$index][1][0] == 'qna_block') && ($input[$index][1][1][0] == 'open_question')) {
354                        $input[$index][1][1][3] = $correction[1];
355                    }
356                    break;
357            }
358        }
359
360        if (!$delete) {
361            $output[] = $input[$index];
362        }
363    }
364
365    /**
366     *
367     */
368    private function applyAppend(&$output) {
369        $lastCall = end($output);
370        $position = $lastCall[2];
371
372        foreach ($this->correction[-1] as $correction) {
373            switch ($correction[0]) {
374                case self::INSERT:
375                    $output[] = array($correction[1][0], $correction[1][1], $position);
376                    break;
377            }
378        }
379    }
380}