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