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}