1<?php 2/** 3 * Plugin Button : Add button with image support syntax for links 4 * 5 * To be run with Dokuwiki only 6 7 * 8 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 9 * @author Rémi Peyronnet <remi+xslt@via.ecp.fr> 10 11 Full Syntax : 12 [[{namespace:image|extra css}wiki page|Title of the link]] 13 14 All fields optional, minimal syntax: 15 [[{}Simple button]] 16 17 Configuration : 18 [[{conf.styles}style|css]] 19 [[{conf.target}style|target]] 20 21 19/05/2013 : Initial release 22 20/04/2014 : Added target support (feature request from Andrew St Hilaire) 23 07/06/2014 : Added dokuwiki formatting support in title section (not working in wiki page section) (feature request from Willi Lethert) 24 30/08/2014 : Added toolbar button (contribution from Xavier Decuyper) and fixed local anchor (bug reported by Andreas Kuzma) 25 06/09/2014 : Refactored to add backlinks support (feature request from Schümmer Hans-Jürgen) 26 28/04/2015 : Refactored global config handling, add internal media link support, add escaping of userinput (contribution from Peter Stumm https://github.com/lisps/plugin-button) 27 05/08/2015 : Merged lisps default style option and added french translation 28 12/09/2015 : Fixed PHP error 29 30/04/2020 : Fixed spaces in image field 30 04/08/2020 : Quick hack to add compatibility with hogfather 31 07/02/2022 : Added Português do Brasil translation (PR by mac-sousa) 32 26/11/2022 : Fixed PHP8.1 warnings 33 13/12/2022 : Fixed PHP7 with str_contains polyfill 34 05/01/2025 : Added support for interwiki links (#35) 35 36 @author ThisNameIsNotAllowed 37 17/11/2016 : Added generation of metadata 38 18/11/2016 : Added default target for external links 39 40 @author lisps 41 05/03/2017 : Merged lisps move compatibility fixes 42 43 @author nerun 44 18/08/2023 : Fixed deprecation warnings in PHP 8+ (#33) 45 46 Knwon bugs: 47 - handle / render repartition is not optimal regarding caching, most of the processing should be moved (#14) 48*/ 49 50 51use dokuwiki\File\PageResolver; 52use dokuwiki\File\MediaResolver; 53 54if (!defined('DOKU_INC')) 55 die(); 56if (!defined('DOKU_PLUGIN')) 57 define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/'); 58 59/* 2020-08-04 - This is a quick hack to fix compatibility issue with hogfather (see issue #13) : 60 * It seems that the handler.php file is no more loaded when rendering cached contents, causing a crash. 61 * This is due to a bad initial conception of this plugin that does not comply to dokuwiki's guidance of handle / render repartition. 62 * 63 * FIXME : refactor handle / render repartition ; most of the processing should be moved in the handle section. 64 * /!\ to be able to do that (and thus, modify the cached content structure) need to find a way to clear the cache while upgrading the plugin... 65 */ 66require_once(DOKU_INC . 'inc/parser/handler.php'); 67 68// str_contains polyfill for PHP versions before PHP8 (by jnv - #26) 69if (!function_exists('str_contains')) { 70 function str_contains(string $haystack, string $needle): bool 71 { 72 return '' === $needle || false !== strpos($haystack, $needle); 73 } 74} 75 76class syntax_plugin_button extends DokuWiki_Syntax_Plugin 77{ 78 79 function getType() 80 { 81 return 'substition'; 82 } 83 function getPType() 84 { 85 return 'normal'; 86 } 87 function getSort() 88 { 89 return 25; 90 } // Internal link is 300 91 92 function connectTo($mode) 93 { 94 $this->Lexer->addSpecialPattern('\[\[{conf[^}]*}[^\]]*\]\]', $mode, 'plugin_button'); 95 $this->Lexer->addEntryPattern('\[\[{[^}]*}[^\]\|]*\|?', $mode, 'plugin_button'); 96 $this->Lexer->addExitPattern(']]', 'plugin_button'); 97 } 98 function postConnect() 99 { 100 } 101 function getAllowedTypes() 102 { 103 return array('formatting', 'substition'); 104 } 105 106 107 108 protected $confStyles; 109 protected $styles = array(); 110 protected $targets = array(); 111 protected function setStyle($name, $value) 112 { 113 global $ID; 114 $this->styles[$ID][$name] = $value; 115 } 116 protected function getStyle($name) 117 { 118 global $ID; 119 return isset($this->styles[$ID][$name]) ? $this->styles[$ID][$name] : $this->getConfStyles($name); 120 } 121 protected function hasStyle($name) 122 { 123 global $ID; 124 return (array_key_exists($ID, $this->styles) && is_array($this->styles[$ID]) && array_key_exists($name, $this->styles[$ID])) 125 || $this->getConfStyles($name) ? true : false; 126 } 127 protected function getConfStyles($name = null) 128 { 129 if ($this->confStyles === null) { 130 $this->confStyles = array(); 131 132 $styles = $this->getConf('styles'); 133 if (!$styles) 134 return; 135 136 $styles = explode("\n", $styles); 137 if (!is_array($styles)) 138 return; 139 140 foreach ($styles as $style) { 141 $style = trim($style); 142 if (!$style) 143 continue; 144 145 if (str_contains($style, '|')) { 146 147 $style = explode('|', $style, 2); 148 if (!is_array($style) || !$style[0] || !$style[1]) 149 continue; 150 151 $this->confStyles[trim($style[0])] = trim($style[1]); 152 } 153 } 154 //dbg($this->confStyles); 155 156 } 157 158 if ($name) { 159 if (!isset($this->confStyles[$name])) 160 return false; 161 162 return $this->confStyles[$name]; 163 } 164 return $this->confStyles; 165 166 } 167 168 169 protected function setTarget($name, $value) 170 { 171 global $ID; 172 $this->targets[$ID][$name] = $value; 173 } 174 protected function getTarget($name) 175 { 176 global $ID; 177 return $this->targets[$ID][$name]; 178 } 179 protected function hasTarget($name) 180 { 181 global $ID; 182 return (array_key_exists($ID, $this->targets) && is_array($this->targets[$ID]) && array_key_exists($name, $this->targets[$ID])) ? true : false; 183 } 184 185 function handle($match, $state, $pos, Doku_Handler $handler) 186 { 187 global $plugin_button_styles; 188 global $plugin_button_target; 189 190 switch ($state) { 191 case DOKU_LEXER_SPECIAL: 192 case DOKU_LEXER_ENTER: 193 $data = ''; 194 // Button 195 if (preg_match('/\[\[{ *(?<image>[^}\|]*) *\|?(?<css>[^}]*)}(?<link>[^\]\|]*)\|?(?<title>[^\]]*)/', $match, $matches)) { 196 $data = $matches; 197 } 198 if (is_array($data)) { 199 if ($data['image'] == 'conf.styles') { 200 $this->setStyle($data['link'], $data['title']); 201 } else if ($data['image'] == 'conf.target') { 202 $this->setTarget($data['link'], $data['title']); 203 } else { 204 $data['target'] = ""; 205 if ($this->hasTarget($data['css'])) { 206 $data['target'] = $this->getTarget($data['css']); 207 } else if ($this->hasTarget('default')) { 208 $data['target'] = $this->getTarget('default'); 209 } 210 211 212 if ($data['css'] != "" && $this->hasStyle($data['css'])) { 213 $data['css'] = $this->getStyle($data['css']); 214 } 215 216 if ($this->hasStyle('default') && ($data['css'] != 'default')) { 217 $data['css'] = $this->getStyle('default') . ' ; ' . $data['css']; 218 } 219 } 220 } 221 222 return array($state, $data); 223 224 case DOKU_LEXER_UNMATCHED: 225 return array($state, $match); 226 case DOKU_LEXER_EXIT: 227 return array($state, ''); 228 } 229 return array(); 230 } 231 232 function render($mode, Doku_Renderer $renderer, $data) 233 { 234 global $plugin_button_styles; 235 global $plugin_button_target; 236 global $ID; 237 global $conf; 238 239 if ($mode == 'xhtml') { 240 list($state, $match) = $data; 241 switch ($state) { 242 case DOKU_LEXER_SPECIAL: 243 case DOKU_LEXER_ENTER: 244 if (is_array($match)) { 245 $image = $match['image']; 246 if (($image != "conf.target") && ($image != "conf.styles")) { 247 // Test if internal or external link (from handler.php / internallink) 248 // 2020-07-09 : added special prefix '!' to allow other URI schemes without '//' in it (ex : apt,...) 249 $force_uri_prefix = "!"; // "/" can be confused with url, "!" not working 250 if ((substr($match['link'], 0, strlen($force_uri_prefix)) === $force_uri_prefix) || (preg_match('#^mailto:|^([a-z0-9\-\.+]+?)://#i', $match['link']))) { 251 // External 252 $link['url'] = $match['link']; 253 // Strip trailing prefix 254 if (substr($link['url'], 0, strlen($force_uri_prefix)) === $force_uri_prefix) { 255 $link['url'] = substr($link['url'], strlen($force_uri_prefix)); 256 } 257 // Check if it is an allowed protocol 258 $link_items = explode(":", $link['url']); 259 // Adds mailto as it is implicitely allowed wih mail syntax. 260 if (!in_array($link_items[0], getSchemes() + array('mailto'))) { 261 $link['url'] = "Unauthorized URI scheme"; 262 } 263 $link['name'] = $match['title']; 264 if ($link['name'] == "") 265 $link['name'] = $match['link']; 266 $link['class'] = 'urlextern'; 267 if (strlen($match['target']) == 0) { 268 $match['target'] = $conf['target']['extern']; 269 } 270 } else { 271 // Internal 272 $link = $this->dokuwiki_get_link($renderer, $match['link'], $match['title']); 273 } 274 $target = $match['target']; 275 if ($target) 276 $target = " target ='" . hsc($target) . "' "; 277 278 $link['name'] = str_replace('\\\\', '<br />', $link['name']); //textbreak support 279 if ($image != '') { 280 $image = Doku_Handler_Parse_Media("{{" . $image . "}}"); 281 $image = $this->internalmedia($renderer, $image['src'], null, null, $image['width'], $image['height']); 282 $image = "<span class='plugin_button_image'>" . $image['name'] . "</span>"; 283 } 284 $text = "<a " . $target . " href='" . $link['url'] . "'><span class='plugin_button' style='" . hsc($match['css']) . "'>$image<span class='plugin_button_text ${link['class']}'>"; 285 if (substr($match[0], -1) != "|") 286 $text .= $link['name']; 287 $renderer->doc .= $text; 288 // Update meta data for move 289 p_set_metadata( 290 $ID, 291 array( 292 'relation' => array( 293 'references' => array( 294 $match['link'] => true, 295 ), 296 'media' => array( 297 $match['image'] => true, 298 ), 299 ), 300 'plugin_move' => array( 301 'pages' => array( 302 $match['link'], 303 ), 304 'medias' => array( 305 $match['image'], 306 ), 307 ), 308 ) 309 ); 310 } 311 } 312 break; 313 314 case DOKU_LEXER_UNMATCHED: 315 $renderer->doc .= $renderer->_xmlEntities($match); 316 break; 317 case DOKU_LEXER_EXIT: 318 $renderer->doc .= "</span></span></a>"; 319 break; 320 } 321 return true; 322 } elseif ($mode == 'metadata') { 323 list($state, $match) = $data; 324 switch ($state) { 325 case DOKU_LEXER_SPECIAL: 326 case DOKU_LEXER_ENTER: 327 if (is_array($match)) { 328 /** @var Doku_Renderer_metadata $renderer */ 329 $renderer->internallink($match['link']); 330 // I am assuming that when processing in handle(), you have stored 331 // the link destination in $data[0] 332 return true; 333 } 334 break; 335 case DOKU_LEXER_UNMATCHED: 336 break; 337 case DOKU_LEXER_EXIT: 338 break; 339 } 340 return true; 341 } 342 return false; 343 } 344 345 // TODO: the way we get links from dokuwiki should be completely rewritten 346 // - target: rework repartition between parser/renderer to match dokuwiki guidelines 347 // - try to override $xhtml->_formatLink($link); to avoid the code duplication of the functions below 348 349 function dokuwiki_get_link(&$xhtml, $id, $name = NULL) 350 { 351 global $ID; 352 353 if (link_isinterwiki($id)) { 354 [$wikiName, $wikiUri] = sexplode('>', $id, 2, ''); 355 $exists = null; 356 //$url = $xhtml->_resolveInterWiki($wikiName, $wikiUri, $exists); 357 $link = $this->interwikilink($xhtml, $id, $name, $wikiName, $wikiUri, true); 358 return $link; 359 } 360 361 $resolveid = $id; // To prevent resolve_pageid to change $id value 362 $resolveid = (new PageResolver($ID))->resolveId($resolveid); 363 $exists = page_exists($resolveid); 364 if ($exists) { 365 return $this->internallink($xhtml, $id, $name); 366 } 367 $resolveid = $id; 368 $resolveid = (new MediaResolver($ID))->resolveId($resolveid); 369 $exists = media_exists($resolveid); 370 if ($exists) { 371 return $this->internalmedia($xhtml, $id, $name); 372 } else { 373 return $this->internallink($xhtml, $id, $name); 374 } 375 } 376 377 // Copied and adapted from inc/parser/xhtml.php, function internallink (see RPHACK) 378 // Should use wl instead (from commons), but this won't do the trick for the name 379 function internallink(&$xhtml, $id, $name = NULL, $search = NULL, $returnonly = false, $linktype = 'content') 380 { 381 global $conf; 382 global $ID; 383 global $INFO; 384 385 386 $params = ''; 387 $parts = explode('?', $id, 2); 388 if (count($parts) === 2) { 389 $id = $parts[0]; 390 $params = $parts[1]; 391 } 392 393 // For empty $id we need to know the current $ID 394 // We need this check because _simpleTitle needs 395 // correct $id and resolve_pageid() use cleanID($id) 396 // (some things could be lost) 397 if ($id === '') { 398 $id = $ID; 399 } 400 401 // RPHACK for get_link to work with local links '#id' 402 if (substr($id, 0, 1) === '#') { 403 $id = $ID . $id; 404 } 405 // ------- 406 407 // default name is based on $id as given 408 $default = $xhtml->_simpleTitle($id); 409 410 // now first resolve and clean up the $id 411 $id = (new PageResolver($ID))->resolveId($id); 412 $exists = page_exists($id); 413 414 $name = $xhtml->_getLinkTitle($name, $default, $isImage, $id, $linktype); 415 if (!$isImage) { 416 if ($exists) { 417 $class = 'wikilink1'; 418 } else { 419 $class = 'wikilink2'; 420 $link['rel'] = 'nofollow'; 421 } 422 } else { 423 $class = 'media'; 424 } 425 426 //keep hash anchor 427 $hash = NULL; 428 if (str_contains($id, '#')) 429 list($id, $hash) = explode('#', $id, 2); 430 if (!empty($hash)) 431 $hash = $xhtml->_headerToLink($hash); 432 433 //prepare for formating 434 $link['target'] = $conf['target']['wiki']; 435 $link['style'] = ''; 436 $link['pre'] = ''; 437 $link['suf'] = ''; 438 // highlight link to current page 439 if ($id == $INFO['id']) { 440 $link['pre'] = '<span class="curid">'; 441 $link['suf'] = '</span>'; 442 } 443 $link['more'] = ''; 444 $link['class'] = $class; 445 $link['url'] = wl($id, $params); 446 $link['name'] = $name; 447 $link['title'] = $id; 448 //add search string 449 if ($search) { 450 ($conf['userewrite']) ? $link['url'] .= '?' : $link['url'] .= '&'; 451 if (is_array($search)) { 452 $search = array_map('rawurlencode', $search); 453 $link['url'] .= 's[]=' . join('&s[]=', $search); 454 } else { 455 $link['url'] .= 's=' . rawurlencode($search); 456 } 457 } 458 459 //keep hash 460 if ($hash) 461 $link['url'] .= '#' . $hash; 462 463 return $link; 464 //output formatted 465 //if($returnonly){ 466 // return $this->_formatLink($link); 467 //}else{ 468 // $this->doc .= $this->_formatLink($link); 469 //} 470 } 471 472 473 function internalmedia( 474 &$xhtml, 475 $src, 476 $title = NULL, 477 $align = NULL, 478 $width = NULL, 479 $height = NULL, 480 $cache = NULL, 481 $linking = NULL 482 ) { 483 global $ID; 484 485 $hash = NULL; 486 if (str_contains($src, '#')) 487 list($src, $hash) = explode('#', $src, 2); 488 $src = (new MediaResolver($ID))->resolveId($src); 489 $exists = media_exists($src); 490 491 $noLink = false; 492 $render = ($linking == 'linkonly') ? false : true; 493 $link = $xhtml->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render); 494 495 list($ext, $mime, $dl) = mimetype($src, false); 496 if (substr($mime, 0, 5) == 'image' && $render) { 497 $link['url'] = ml($src, array('id' => $ID, 'cache' => $cache), ($linking == 'direct')); 498 } elseif ($mime == 'application/x-shockwave-flash' && $render) { 499 // don't link flash movies 500 $noLink = true; 501 } else { 502 // add file icons 503 $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); 504 $link['class'] .= ' mediafile mf_' . $class; 505 $link['url'] = ml($src, array('id' => $ID, 'cache' => $cache), true); 506 if ($exists) 507 $link['title'] .= ' (' . filesize_h(filesize(mediaFN($src))) . ')'; 508 } 509 510 if ($hash) 511 $link['url'] .= '#' . $hash; 512 513 //markup non existing files 514 if (!$exists) { 515 $link['class'] .= ' wikilink2'; 516 } 517 518 return $link; 519 //output formatted 520 //if ($linking == 'nolink' || $noLink) $this->doc .= $link['name']; 521 //else $this->doc .= $this->_formatLink($link); 522 } 523 524 public function interwikilink(&$xhtml, $match, $name, $wikiName, $wikiUri, $returnonly = false) 525 { 526 global $conf; 527 528 $link = []; 529 $link['target'] = $conf['target']['interwiki']; 530 $link['pre'] = ''; 531 $link['suf'] = ''; 532 $link['more'] = ''; 533 $link['name'] = $xhtml->_getLinkTitle($name, $wikiUri, $isImage); 534 $link['rel'] = ''; 535 536 //get interwiki URL 537 $exists = null; 538 $url = $xhtml->_resolveInterWiki($wikiName, $wikiUri, $exists); 539 540 if (!$isImage) { 541 $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $wikiName); 542 $link['class'] = "interwiki iw_$class"; 543 } else { 544 $link['class'] = 'media'; 545 } 546 547 //do we stay at the same server? Use local target 548 if (strpos($url, DOKU_URL) === 0 || strpos($url, DOKU_BASE) === 0) { 549 $link['target'] = $conf['target']['wiki']; 550 } 551 if ($exists !== null && !$isImage) { 552 if ($exists) { 553 $link['class'] .= ' wikilink1'; 554 } else { 555 $link['class'] .= ' wikilink2'; 556 $link['rel'] .= ' nofollow'; 557 } 558 } 559 if ($conf['target']['interwiki']) $link['rel'] .= ' noopener'; 560 561 $link['url'] = $url; 562 $link['title'] = $xhtml->_xmlEntities($link['url']); 563 564 // return non formatted link 565 return $link; 566 567 /* 568 // output formatted 569 if ($returnonly) { 570 if ($url == '') return $link['name']; 571 return $this->_formatLink($link); 572 } elseif ($url == '') { 573 $this->doc .= $link['name']; 574 } else $this->doc .= $this->_formatLink($link); 575 */ 576 } 577} 578 579?>