1<?php 2 3/** 4 * DokuWiki Plugin dwtimeline (Syntax Component) 5 * 6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 7 * @author saggi <saggi@gmx.de> 8 */ 9 10use dokuwiki\Extension\SyntaxPlugin; 11use dokuwiki\File\PageResolver; 12 13class syntax_plugin_dwtimeline_dwtimeline extends SyntaxPlugin 14{ 15 /** 16 * Global direction memory 17 * @var 18 */ 19 protected static $direction; 20 protected static $align; 21 22 /** @inheritDoc */ 23 public function getType() 24 { 25 return 'substition'; 26 } 27 28 /** @inheritDoc */ 29 public function getPType() 30 { 31 return 'stack'; 32 } 33 34 /** @inheritDoc */ 35 public function getSort() 36 { 37 return 400; 38 } 39 40 /** 41 * Change the current content of $direction String (left,right) 42 * @param string $direction 43 * @return string 44 */ 45 public function changeDirection(string $direction): string 46 { 47 if ($direction === 'tl-right') { 48 $direction = 'tl-left'; 49 } else { 50 $direction = 'tl-right'; 51 } 52 return $direction; 53 } 54 55 public function getDirection() 56 { 57 if (!self::$direction) { 58 self::$direction = 'tl-' . $this->getConf('direction'); 59 } 60 return self::$direction; 61 } 62 63 /** 64 * Handle the match 65 * @param string $match The match of the syntax 66 * @param int $state The state of the handler 67 * @param int $pos The position in the document 68 * @param Doku_Handler $handler The handler 69 * @return array Data for the renderer 70 */ 71 public function handle($match, $state, $pos, Doku_Handler $handler) 72 { 73 return []; 74 } 75 76 /** 77 * Create output 78 * 79 * @param string $mode string output format being rendered 80 * @param Doku_Renderer $renderer the current renderer object 81 * @param array $data data created by handler() 82 * @return bool rendered correctly? 83 */ 84 public function render($mode, Doku_Renderer $renderer, $data) 85 { 86 return false; 87 } 88 89 /** 90 * Match entity options like: <dwtimeline opt1="value1" opt2='value2'> 91 * Returns normalized data array used by the renderer. 92 */ 93 public function getTitleMatches(string $match): array 94 { 95 // defaults 96 $data = [ 97 'align' => self::$align, // standard alignment 98 'data' => '', 99 'style' => ' style="', 100 ]; 101 102 $opts = $this->parseOptions($match); 103 104 foreach ($opts as $option => $rawValue) { 105 switch ($option) { 106 case 'link': 107 $data['link'] = $this->getLink($rawValue); 108 break; 109 110 case 'data': 111 $datapoint = substr($rawValue, 0, 4); 112 $data['data'] = ' data-point="' . hsc($datapoint) . '" '; 113 if (strlen($datapoint) > 2) { 114 $data['style'] .= '--4sizewidth: 50px; --4sizeright: -29px; --4sizesmallleft40: 60px; '; 115 $data['style'] .= '--4sizesmallleft50: 70px; --4sizesmallleft4: -10px; '; 116 $data['style'] .= '--4sizewidthhorz: 50px; --4sizerighthorz: -29px; '; 117 } 118 break; 119 120 case 'align': 121 $data['align'] = $this->checkValues($rawValue, ['horz', 'vert'], self::$align); 122 break; 123 124 case 'backcolor': 125 if ($c = $this->isValidColor($rawValue)) { 126 $data['style'] .= 'background-color:' . $c . '; '; 127 } 128 break; 129 130 case 'style': 131 // do not accept custom styles at the moment 132 break; 133 134 default: 135 // generic attributes (e.g., title) 136 $data[$option] = hsc($rawValue); // HTML-escape for output later 137 break; 138 } 139 } 140 141 // close style if something was added 142 $data['style'] = ($data['style'] === ' style="') ? '' : $data['style'] . '"'; 143 144 return $data; 145 } 146 147 /** 148 * Parse HTML-like attributes from a string. 149 * Supports: key="val", key='val', key=val (unquoted), with \" and \\ in "..." 150 * Note: PREG_UNMATCHED_AS_NULL requires PHP 7.2+. 151 */ 152 private function parseOptions(string $s): array 153 { 154 $out = []; 155 $i = 0; 156 $len = strlen($s); 157 158 $pattern = '/\G\s*(?P<name>[a-zA-Z][\w-]*)\s*' 159 . '(?:=\s*(?:"(?P<dq>(?:[^"\\\\]|\\\\.)*)"' 160 . '|\'(?P<sq>(?:[^\'\\\\]|\\\\.)*)\'' 161 . '|\[\[(?P<br>.+?)\]\]' 162 . '|(?P<uq>[^\s"\'=<>`]+)))?' 163 . '/A'; 164 165 while ($i < $len) { 166 if (!preg_match($pattern, $s, $m, PREG_UNMATCHED_AS_NULL, $i)) { 167 break; 168 } 169 $i += strlen($m[0]); 170 171 $name = strtolower($m['name']); 172 $raw = $m['dq'] ?? $m['sq'] ?? ($m['br'] !== null ? '[[' . $m['br'] . ']]' : null) ?? $m['uq'] ?? ''; 173 if ($m['dq'] !== null || $m['sq'] !== null) { 174 $raw = stripcslashes($raw); // \" und \\ in quoted Werten ent-escapen 175 } 176 $out[$name] = $raw; 177 } 178 return $out; 179 } 180 181 /** 182 * Return the first link target found in the given wiki text. 183 * Supports internal links [[id|label]], external links (bare or bracketed), 184 * interwiki, mailto and Windows share. Returns a normalized target: 185 * - internal: absolute page id, incl. optional "#section" 186 * - external: absolute URL (http/https/ftp) 187 * - email: mailto:<addr> 188 * - share: \\server\share\path 189 * Returns '' if none found. 190 */ 191 public function getLink(string $wikitext): string 192 { 193 $ins = p_get_instructions($wikitext); 194 if (!$ins) { 195 return ''; 196 } 197 198 global $ID; 199 $resolver = new PageResolver($ID); 200 201 foreach ($ins as $node) { 202 $type = $node[0]; 203 // INTERNAL WIKI LINK [[ns:page#section|label]] 204 if ($type === 'internallink') { 205 $raw = $node[1][0] ?? ''; 206 if ($raw === '') { 207 continue; 208 } 209 210 $anchor = ''; 211 if (strpos($raw, '#') !== false) { 212 [$rawId, $sec] = explode('#', $raw, 2); 213 $raw = trim($rawId); 214 $anchor = '#' . trim($sec); 215 } else { 216 $raw = trim($raw); 217 } 218 219 $abs = $resolver->resolveId(cleanID($raw)); 220 return $abs . $anchor; 221 } 222 223 // EXTERNAL LINK (bare URL or [[http(s)/ftp://...|label]]) 224 if ($type === 'externallink') { 225 // payload can be scalar or array depending on DW version 226 $url = is_array($node[1]) ? (string)($node[1][0] ?? '') : (string)$node[1]; 227 return trim($url); 228 } 229 230 // INTERWIKI [[wp>Foo]] etc. – return the canonical "prefix>page" 231 if ($type === 'interwikilink') { 232 $raw = $node[1][0] ?? ''; 233 if ($raw === '') { 234 continue; 235 } 236 return $raw; 237 } 238 239 // EMAIL 240 if ($type === 'emaillink') { 241 $addr = is_array($node[1]) ? (string)($node[1][0] ?? '') : (string)$node[1]; 242 return 'mailto:' . trim($addr); 243 } 244 245 // WINDOWS SHARE 246 if ($type === 'windowssharelink') { 247 $path = is_array($node[1]) ? (string)($node[1][0] ?? '') : (string)$node[1]; 248 return trim($path); 249 } 250 } 251 252 // Fallback: detect bare URL or email if no instruction was emitted 253 if (preg_match('/\b(?:https?|ftp):\/\/\S+/i', $wikitext, $m)) { 254 return rtrim($m[0], '.,);'); 255 } 256 if (preg_match('/^[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}$/', trim($wikitext), $m)) { 257 return 'mailto:' . $m[0]; 258 } 259 260 return ''; 261 } 262 263 public function checkValues($toCheck, $allowed, $standard) 264 { 265 if (in_array($toCheck, $allowed, true)) { 266 return $toCheck; 267 } else { 268 return $standard; 269 } 270 } 271 272 /** 273 * Validate color value $color 274 * this is cut price validation - only to ensure the basic format is correct and there is nothing harmful 275 * three basic formats "colorname", "#fff[fff]", "rgb(255[%],255[%],255[%])" 276 */ 277 public function isValidColor($color) 278 { 279 $color = trim($color); 280 $colornames = [ 281 'AliceBlue', 282 'AntiqueWhite', 283 'Aqua', 284 'Aquamarine', 285 'Azure', 286 'Beige', 287 'Bisque', 288 'Black', 289 'BlanchedAlmond', 290 'Blue', 291 'BlueViolet', 292 'Brown', 293 'BurlyWood', 294 'CadetBlue', 295 'Chartreuse', 296 'Chocolate', 297 'Coral', 298 'CornflowerBlue', 299 'Cornsilk', 300 'Crimson', 301 'Cyan', 302 'DarkBlue', 303 'DarkCyan', 304 'DarkGoldenRod', 305 'DarkGray', 306 'DarkGrey', 307 'DarkGreen', 308 'DarkKhaki', 309 'DarkMagenta', 310 'DarkOliveGreen', 311 'DarkOrange', 312 'DarkOrchid', 313 'DarkRed', 314 'DarkSalmon', 315 'DarkSeaGreen', 316 'DarkSlateBlue', 317 'DarkSlateGray', 318 'DarkSlateGrey', 319 'DarkTurquoise', 320 'DarkViolet', 321 'DeepPink', 322 'DeepSkyBlue', 323 'DimGray', 324 'DimGrey', 325 'DodgerBlue', 326 'FireBrick', 327 'FloralWhite', 328 'ForestGreen', 329 'Fuchsia', 330 'Gainsboro', 331 'GhostWhite', 332 'Gold', 333 'GoldenRod', 334 'Gray', 335 'Grey', 336 'Green', 337 'GreenYellow', 338 'HoneyDew', 339 'HotPink', 340 'IndianRed', 341 'Indigo', 342 'Ivory', 343 'Khaki', 344 'Lavender', 345 'LavenderBlush', 346 'LawnGreen', 347 'LemonChiffon', 348 'LightBlue', 349 'LightCoral', 350 'LightCyan', 351 'LightGoldenRodYellow', 352 'LightGray', 353 'LightGrey', 354 'LightGreen', 355 'LightPink', 356 'LightSalmon', 357 'LightSeaGreen', 358 'LightSkyBlue', 359 'LightSlateGray', 360 'LightSlateGrey', 361 'LightSteelBlue', 362 'LightYellow', 363 'Lime', 364 'LimeGreen', 365 'Linen', 366 'Magenta', 367 'Maroon', 368 'MediumAquaMarine', 369 'MediumBlue', 370 'MediumOrchid', 371 'MediumPurple', 372 'MediumSeaGreen', 373 'MediumSlateBlue', 374 'MediumSpringGreen', 375 'MediumTurquoise', 376 'MediumVioletRed', 377 'MidnightBlue', 378 'MintCream', 379 'MistyRose', 380 'Moccasin', 381 'NavajoWhite', 382 'Navy', 383 'OldLace', 384 'Olive', 385 'OliveDrab', 386 'Orange', 387 'OrangeRed', 388 'Orchid', 389 'PaleGoldenRod', 390 'PaleGreen', 391 'PaleTurquoise', 392 'PaleVioletRed', 393 'PapayaWhip', 394 'PeachPuff', 395 'Peru', 396 'Pink', 397 'Plum', 398 'PowderBlue', 399 'Purple', 400 'RebeccaPurple', 401 'Red', 402 'RosyBrown', 403 'RoyalBlue', 404 'SaddleBrown', 405 'Salmon', 406 'SandyBrown', 407 'SeaGreen', 408 'SeaShell', 409 'Sienna', 410 'Silver', 411 'SkyBlue', 412 'SlateBlue', 413 'SlateGray', 414 'SlateGrey', 415 'Snow', 416 'SpringGreen', 417 'SteelBlue', 418 'Tan', 419 'Teal', 420 'Thistle', 421 'Tomato', 422 'Turquoise', 423 'Violet', 424 'Wheat', 425 'White', 426 'WhiteSmoke', 427 'Yellow', 428 'YellowGreen' 429 ]; 430 431 if (in_array(strtolower($color), array_map('strtolower', $colornames))) { 432 return $color; 433 } 434 435 $pattern = '/^\s*( 436 (\#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))| #colorvalue 437 (rgb\(([0-9]{1,3}%?,){2}[0-9]{1,3}%?\)) #rgb triplet 438 )\s*$/x'; 439 440 if (preg_match($pattern, $color)) { 441 return trim($color); 442 } 443 444 return false; 445 } 446 447 /** 448 * Localized error helper with ARIA for screen readers. 449 */ 450 public function err(string $langKey, array $sprintfArgs = []): string 451 { 452 $txt = $this->getLang($langKey) ?? $langKey; 453 if ($sprintfArgs) { 454 $sprintfArgs = array_map('hsc', $sprintfArgs); 455 $txt = vsprintf($txt, $sprintfArgs); 456 } else { 457 $txt = hsc($txt); 458 } 459 460 return '<div class="plugin_dwtimeline_error" role="status" aria-live="polite">' 461 . $txt 462 . '</div>'; 463 } 464 465 /** 466 * Return a human-friendly page title for $id. 467 * 1) metadata title 468 * 2) first heading (if available) 469 * 3) pretty formatted ID with namespaces (e.g. "Ns › Sub › Page") 470 */ 471 public function prettyId(string $id): string 472 { 473 // 1) meta title, if exist 474 $metaTitle = p_get_metadata($id, 'title'); 475 if (is_string($metaTitle) && $metaTitle !== '') { 476 return $metaTitle; 477 } 478 479 // 2) First header 480 if (function_exists('p_get_first_heading')) { 481 $h = p_get_first_heading($id); 482 if (is_string($h) && $h !== '') { 483 return $h; 484 } 485 } 486 487 // 3) fallback: path to page 488 $parts = explode(':', $id); 489 foreach ($parts as &$p) { 490 $p = str_replace('_', ' ', $p); 491 $p = mb_convert_case($p, MB_CASE_TITLE, 'UTF-8'); 492 } 493 return implode(' › ', $parts); 494 } 495 496 /** 497 * Quote a value for wiki-style plugin attributes. 498 * Prefers "..." if possible, then '...'. If both quote types occur, 499 * wrap with " and escape inner \" and \\ (the parser will unescape them). 500 */ 501 public function quoteAttrForWiki(string $val): string 502 { 503 if (strpos($val, '"') === false) { 504 return '"' . $val . '"'; 505 } 506 if (strpos($val, "'") === false) { 507 return "'" . $val . "'"; 508 } 509 510 // contains both ' and " -> escape for double-quoted 511 $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $val); 512 return '"' . $escaped . '"'; 513 } 514 515 /** 516 * Return the index (byte offset) directly after the end of the line containing $pos. 517 */ 518 public function lineEndAt(string $text, int $pos, int $len): int 519 { 520 if ($pos < 0) { 521 return 0; 522 } 523 $nl = strpos($text, "\n", $pos); 524 return ($nl === false) ? $len : ($nl + 1); 525 } 526 527 /** 528 * Return the start index (byte offset) of the line containing $pos. 529 */ 530 public function lineStartAt(string $text, int $pos): int 531 { 532 if ($pos <= 0) { 533 return 0; 534 } 535 $before = substr($text, 0, $pos); 536 $nl = strrpos($before, "\n"); 537 return ($nl === false) ? 0 : ($nl + 1); 538 } 539 540 /** 541 * Cut a section [start, end) from $text and rtrim it on the right side. 542 */ 543 public function cutSection(string $text, int $start, int $end): string 544 { 545 if ($start < 0) { 546 $start = 0; 547 } 548 if ($end < $start) { 549 $end = $start; 550 } 551 return rtrim(substr($text, $start, $end - $start)); 552 } 553} 554