1<?php 2/** 3 * Move Plugin Rewriting Handler 4 * 5 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 6 * @author Michael Hamann <michael@content-space.de> 7 */ 8 9use dokuwiki\Extension\Plugin; 10use dokuwiki\File\MediaResolver; 11use dokuwiki\File\PageResolver; 12use dokuwiki\Logger; 13use dokuwiki\plugin\move\MoveResolver; 14 15/** 16 * Handler class for move. It does the actual rewriting of the content. 17 * 18 * It is not really a helper plugin. 19 * 20 * This should theoretically be a descendant of Doku_Handler. This would require to implement and overwrite 21 * all the mode handler methods, which would probably be the cleaner approach. For now we pretend to be a 22 * Doku_Handler, implement only the methods we need and use __call() to catch all the rest. 23 * 24 * Since Parser does not like that and expects a Doku_Handler in the constructor, we use reflection to 25 * inject our fake handler into the Parser in helper_plugin_move_rewrite:rewrite() 26 * 27 * This is not really nice, but it works. It has at leas the advantage that we would not need to update this 28 * class when a new handler is introduced - though that hasn't happened in literally decades. 29 * 30 * The other hack we're doing here is to directly append (rewritten) matches to $wikitext. This makes it easy 31 * to basically keep the syntax as is and only change the IDs. A more proper approach would be create 32 * calls, then reverse-render them in the end. This can lead to slight differences between the original syntax 33 * and the reverse-rendered one, e.g. where syntax is somewhat ambiguous 34 * 35 * @todo At least do the right thing an make this a proper Doku_Handler descendant even when we keep the wikitext 36 */ 37class helper_plugin_move_handler extends Plugin { 38 public $calls = []; 39 40 /** 41 * @var string This handler does not create calls, but rather recreates wiki text in one go. 42 */ 43 public $wikitext = ''; 44 45 protected $id; 46 protected $ns; 47 protected $origID; 48 protected $origNS; 49 protected $page_moves; 50 protected $media_moves; 51 protected $handlers; 52 53 54 /** 55 * Initialize the move handler. 56 * 57 * @param string $id The id of the text that is passed to the handler 58 * @param string $original The name of the original ID of this page. Same as $id if this page wasn't moved 59 * @param array $page_moves Moves that shall be considered in the form [[$old,$new],...] ($old can be $original) 60 * @param array $media_moves Moves of media files that shall be considered in the form $old => $new 61 * @param array $handlers Handlers for plugin content in the form $plugin_name => $callback 62 */ 63 public function init($id, $original, $page_moves, $media_moves, $handlers) { 64 $this->id = $id; 65 $this->ns = getNS($id); 66 $this->origID = $original; 67 $this->origNS = getNS($original); 68 $this->page_moves = $page_moves; 69 $this->media_moves = $media_moves; 70 $this->handlers = $handlers; 71 } 72 73 /** 74 * Go through the list of moves and find the new value for the given old ID 75 * 76 * @param string $old the old, full qualified ID 77 * @param string $type 'media' or 'page' 78 * @throws \Exception on bad argument 79 * @return string the new full qualified ID 80 */ 81 public function resolveMoves($old, $type) { 82 global $conf; 83 84 if($type != 'media' && $type != 'page') throw new \Exception('Not a valid type'); 85 86 $resolver = new MoveResolver($this->origID); 87 $old = $resolver->resolveId($old); 88 89 if($type == 'page') { 90 // FIXME this simply assumes that the link pointed to :$conf['start'], but it could also point to another page 91 // resolve_pageid does a lot more here, but we can't really assume this as the original pages might have been 92 // deleted already 93 if(substr($old, -1) === ':' || $old === '') $old .= $conf['start']; 94 95 $moves = $this->page_moves; 96 } else { 97 $moves = $this->media_moves; 98 } 99 100 $old = cleanID($old); 101 102 foreach($moves as $move) { 103 if($move[0] == $old) { 104 $old = $move[1]; 105 } 106 } 107 108 return $old; // this is now new 109 } 110 111 /** 112 * if the old link ended with a colon and the new one is a start page, adjust 113 * 114 * @param $relold string the old, possibly relative ID 115 * @param $new string the new, full qualified ID 116 * @param $type 'media' or 'page' 117 * @return string 118 */ 119 protected function _nsStartCheck($relold, $new, $type) { 120 global $conf; 121 if($type == 'page' && substr($relold, -1) == ':') { 122 $len = strlen($conf['start']); 123 if($new == $conf['start']) { 124 $new = '.:'; 125 } else if(substr($new, -1 * ($len + 1)) == ':' . $conf['start']) { 126 $new = substr($new, 0, -1 * $len); 127 } 128 } 129 return $new; 130 } 131 132 /** 133 * Construct a new ID relative to the current page's location 134 * 135 * Uses a relative link only if the original was relative, too. This function is for 136 * pages and media files. 137 * 138 * @param string $relold the old, possibly relative ID 139 * @param string $new the new, full qualified ID 140 * @param string $type 'media' or 'page' 141 * @throws \Exception on bad argument 142 * @return string 143 */ 144 public function relativeLink($relold, $new, $type) { 145 global $conf; 146 if($type != 'media' && $type != 'page') throw new \Exception('Not a valid type'); 147 148 // first check if the old link still resolves 149 $old = $relold; 150 if($type == 'page') { 151 $resolver = new PageResolver($this->id); 152 } else { 153 $resolver = new MediaResolver($this->id); 154 } 155 $old = $resolver->resolveId($old); 156 if($old == $new) { 157 return $relold; // old link still resolves, keep as is 158 } 159 160 if($conf['useslash']) $relold = str_replace('/', ':', $relold); 161 162 // check if the link was relative 163 if(strpos($relold, ':') === false ||$relold[0] == '.') { 164 $wasrel = true; 165 } else { 166 $wasrel = false; 167 } 168 169 // if it wasn't relative then, leave it absolute now, too 170 if(!$wasrel) { 171 if($this->ns && !getNS($new)) $new = ':' . $new; 172 $new = $this->_nsStartCheck($relold, $new, $type); 173 return $new; 174 } 175 176 // split the paths and see how much common parts there are 177 $selfpath = explode(':', $this->ns); 178 $goalpath = explode(':', getNS($new)); 179 $min = min(count($selfpath), count($goalpath)); 180 for($common = 0; $common < $min; $common++) { 181 if($selfpath[$common] != $goalpath[$common]) break; 182 } 183 184 // we now have the non-common part and a number of uppers 185 $ups = max(count($selfpath) - $common, 0); 186 $remainder = array_slice($goalpath, $common); 187 $upper = $ups ? array_fill(0, $ups, '..:') : array(); 188 189 // build the new relative path 190 $newrel = join(':', $upper); 191 if($remainder) $newrel .= join(':', $remainder) . ':'; 192 $newrel .= noNS($new); 193 $newrel = str_replace('::', ':', trim($newrel, ':')); 194 if($newrel[0] != '.' && $this->ns && getNS($newrel)) $newrel = '.' . $newrel; 195 196 // if the old link ended with a colon and the new one is a start page, adjust 197 $newrel = $this->_nsStartCheck($relold,$newrel,$type); 198 199 // don't use relative paths if it is ridicoulus: 200 if(strlen($newrel) > strlen($new)) { 201 $newrel = $new; 202 if($this->ns && !getNS($new)) $newrel = ':' . $newrel; 203 $newrel = $this->_nsStartCheck($relold,$newrel,$type); 204 } 205 206 return $newrel; 207 } 208 209 /** 210 * Handle camelcase links 211 * 212 * @param string $match The text match 213 * @param string $state The starte of the parser 214 * @param int $pos The position in the input 215 * @return bool If parsing should be continued 216 */ 217 public function camelcaselink($match, $state, $pos) { 218 $oldID = cleanID($this->origNS . ':' . $match); 219 $newID = $this->resolveMoves($oldID, 'page'); 220 $newNS = getNS($newID); 221 222 if($oldID == $newID || $this->origNS == $newNS) { 223 // link is still valid as is 224 $this->wikitext .= $match; 225 } else { 226 if(noNS($oldID) == noNS($newID)) { 227 // only namespace changed, keep CamelCase in link 228 $this->wikitext .= "[[$newNS:$match]]"; 229 } else { 230 // all new, keep CamelCase in title 231 $this->wikitext .= "[[$newID|$match]]"; 232 } 233 } 234 return true; 235 } 236 237 /** 238 * Handle rewriting of internal links 239 * 240 * @param string $match The text match 241 * @param string $state The starte of the parser 242 * @param int $pos The position in the input 243 * @return bool If parsing should be continued 244 */ 245 public function internallink($match, $state, $pos) { 246 // Strip the opening and closing markup 247 $link = preg_replace(array('/^\[\[/', '/\]\]$/u'), '', $match); 248 249 // Split title from URL 250 $link = explode('|', $link, 2); 251 if(!isset($link[1])) { 252 $link[1] = null; 253 } else if(preg_match('/^\{\{[^\}]+\}\}$/', $link[1])) { 254 // If the title is an image, rewrite it 255 $old_title = $link[1]; 256 $link[1] = $this->rewrite_media($link[1]); 257 // do a simple replace of the first match so really only the id is changed and not e.g. the alignment 258 $oldpos = strpos($match, $old_title); 259 $oldlen = strlen($old_title); 260 $match = substr_replace($match, $link[1], $oldpos, $oldlen); 261 } 262 $link[0] = trim($link[0]); 263 264 //decide which kind of link it is 265 266 if(preg_match('/^[a-zA-Z0-9\.]+>{1}.*$/u', $link[0])) { 267 // Interwiki 268 $this->wikitext .= $match; 269 } elseif(preg_match('/^\\\\\\\\[^\\\\]+?\\\\/u', $link[0])) { 270 // Windows Share 271 $this->wikitext .= $match; 272 } elseif(preg_match('#^([a-z0-9\-\.+]+?)://#i', $link[0])) { 273 // external link (accepts all protocols) 274 $this->wikitext .= $match; 275 } elseif(preg_match('<' . PREG_PATTERN_VALID_EMAIL . '>', $link[0])) { 276 // E-Mail (pattern above is defined in inc/mail.php) 277 $this->wikitext .= $match; 278 } elseif(preg_match('!^#.+!', $link[0])) { 279 // local hash link 280 $this->wikitext .= $match; 281 } else { 282 $id = $link[0]; 283 284 $hash = ''; 285 $parts = explode('#', $id, 2); 286 if(count($parts) === 2) { 287 $id = $parts[0]; 288 $hash = $parts[1]; 289 } 290 291 $params = ''; 292 $parts = explode('?', $id, 2); 293 if(count($parts) === 2) { 294 $id = $parts[0]; 295 $params = $parts[1]; 296 } 297 298 $new_id = $this->resolveMoves($id, 'page'); 299 $new_id = $this->relativeLink($id, $new_id, 'page'); 300 301 if($id == $new_id) { 302 $this->wikitext .= $match; 303 } else { 304 if($params !== '') { 305 $new_id .= '?' . $params; 306 } 307 308 if($hash !== '') { 309 $new_id .= '#' . $hash; 310 } 311 312 if($link[1] != null) { 313 $new_id .= '|' . $link[1]; 314 } 315 316 $this->wikitext .= '[[' . $new_id . ']]'; 317 } 318 319 } 320 321 return true; 322 } 323 324 /** 325 * Handle rewriting of media links 326 * 327 * @param string $match The text match 328 * @param string $state The starte of the parser 329 * @param int $pos The position in the input 330 * @return bool If parsing should be continued 331 */ 332 public function media($match, $state, $pos) { 333 $this->wikitext .= $this->rewrite_media($match); 334 return true; 335 } 336 337 /** 338 * Rewrite a media syntax 339 * 340 * @param string $match The text match of the media syntax 341 * @return string The rewritten syntax 342 */ 343 protected function rewrite_media($match) { 344 $p = Doku_Handler_Parse_Media($match); 345 if($p['type'] == 'internalmedia') { // else: external media 346 347 $new_src = $this->resolveMoves($p['src'], 'media'); 348 $new_src = $this->relativeLink($p['src'], $new_src, 'media'); 349 350 if($new_src !== $p['src']) { 351 // do a simple replace of the first match so really only the id is changed and not e.g. the alignment 352 $srcpos = strpos($match, $p['src']); 353 $srclen = strlen($p['src']); 354 return substr_replace($match, $new_src, $srcpos, $srclen); 355 } 356 } 357 return $match; 358 } 359 360 /** 361 * Handle rewriting of plugin syntax, calls the registered handlers 362 * 363 * @param string $match The text match 364 * @param string $state The starte of the parser 365 * @param int $pos The position in the input 366 * @param string $pluginname The name of the plugin 367 * @return bool If parsing should be continued 368 */ 369 public function plugin($match, $state, $pos, $pluginname) { 370 if(isset($this->handlers[$pluginname])) { 371 $this->wikitext .= call_user_func($this->handlers[$pluginname], $match, $state, $pos, $pluginname, $this); 372 } else { 373 $this->wikitext .= $match; 374 } 375 return true; 376 } 377 378 /** 379 * Catchall handler for the remaining syntax 380 * 381 * @param string $name Function name that was called 382 * @param array $params Original parameters 383 * @return bool If parsing should be continue 384 */ 385 public function __call($name, $params) { 386 if(count($params) == 3) { 387 $this->wikitext .= $params[0]; 388 return true; 389 } else { 390 Logger::error( 391 'Error, handler function ' . hsc($name) . ' with ' . count($params) . 392 ' parameters called which isn\'t implemented' 393 ); 394 return false; 395 } 396 } 397 398 public function finalize() { 399 // remove padding that is added by the parser in parse() 400 $this->wikitext = substr($this->wikitext, 1, -1); 401 } 402 403 /** 404 * Get the rewritten wiki text 405 * 406 * @return string The rewritten wiki text 407 */ 408 public function getWikiText() 409 { 410 return $this->wikitext; 411 } 412} 413