1<?php 2 3use dokuwiki\File\MediaResolver; 4use dokuwiki\File\PageResolver; 5use dokuwiki\Utf8\PhpString; 6 7/** 8 * The MetaData Renderer 9 * 10 * Metadata is additional information about a DokuWiki page that gets extracted mainly from the page's content 11 * but also it's own filesystem data (like the creation time). All metadata is stored in the fields $meta and 12 * $persistent. 13 * 14 * Some simplified rendering to $doc is done to gather the page's (text-only) abstract. 15 * 16 * @author Esther Brunner <wikidesign@gmail.com> 17 */ 18class Doku_Renderer_metadata extends Doku_Renderer 19{ 20 /** the approximate byte lenght to capture for the abstract */ 21 public const ABSTRACT_LEN = 250; 22 23 /** the maximum UTF8 character length for the abstract */ 24 public const ABSTRACT_MAX = 500; 25 26 /** @var array transient meta data, will be reset on each rendering */ 27 public $meta = []; 28 29 /** @var array persistent meta data, will be kept until explicitly deleted */ 30 public $persistent = []; 31 32 /** @var array the list of headers used to create unique link ids */ 33 protected $headers = []; 34 35 /** @var string temporary $doc store */ 36 protected $store = ''; 37 38 /** @var string keeps the first image reference */ 39 protected $firstimage = ''; 40 41 /** @var bool whether or not data is being captured for the abstract, public to be accessible by plugins */ 42 public $capturing = true; 43 44 /** @var bool determines if enough data for the abstract was collected, yet */ 45 public $capture = true; 46 47 /** @var int number of bytes captured for abstract */ 48 protected $captured = 0; 49 50 /** 51 * Returns the format produced by this renderer. 52 * 53 * @return string always 'metadata' 54 */ 55 public function getFormat() 56 { 57 return 'metadata'; 58 } 59 60 /** 61 * Initialize the document 62 * 63 * Sets up some of the persistent info about the page if it doesn't exist, yet. 64 */ 65 public function document_start() 66 { 67 global $ID; 68 69 $this->headers = []; 70 71 // external pages are missing create date 72 if (!isset($this->persistent['date']['created']) || !$this->persistent['date']['created']) { 73 $this->persistent['date']['created'] = filectime(wikiFN($ID)); 74 } 75 if (!isset($this->persistent['user'])) { 76 $this->persistent['user'] = ''; 77 } 78 if (!isset($this->persistent['creator'])) { 79 $this->persistent['creator'] = ''; 80 } 81 82 // The table of contents is always rebuilt from the page's headings during rendering and 83 // must never be part of the persistent metadata. Buggy plugin versions and callers of 84 // p_set_metadata() could leak it into the persistent data, which then caused the TOC to be 85 // appended again on every render, resulting in a permanently duplicated TOC (see #3179). 86 // Dropping it here lets such corrupted metadata heal itself on the next render. 87 if (isset($this->persistent['description']['tableofcontents'])) { 88 unset($this->persistent['description']['tableofcontents']); 89 } 90 91 // reset metadata to persistent values 92 $this->meta = $this->persistent; 93 } 94 95 /** 96 * Finalize the document 97 * 98 * Stores collected data in the metadata 99 */ 100 public function document_end() 101 { 102 global $ID; 103 104 // store internal info in metadata (notoc,nocache) 105 $this->meta['internal'] = $this->info; 106 107 if (!isset($this->meta['description']['abstract'])) { 108 // cut off too long abstracts 109 $this->doc = trim($this->doc); 110 if (strlen($this->doc) > self::ABSTRACT_MAX) { 111 $this->doc = PhpString::substr($this->doc, 0, self::ABSTRACT_MAX) . '…'; 112 } 113 $this->meta['description']['abstract'] = $this->doc; 114 } 115 116 $this->meta['relation']['firstimage'] = $this->firstimage; 117 118 if (!isset($this->meta['date']['modified'])) { 119 $this->meta['date']['modified'] = filemtime(wikiFN($ID)); 120 } 121 122 $this->doc = ''; 123 } 124 125 /** 126 * Render plain text data 127 * 128 * This function takes care of the amount captured data and will stop capturing when 129 * enough abstract data is available 130 * 131 * @param $text 132 */ 133 public function cdata($text) 134 { 135 if (!$this->capture || !$this->capturing) { 136 return; 137 } 138 139 $this->doc .= $text; 140 141 $this->captured += strlen($text); 142 if ($this->captured > self::ABSTRACT_LEN) { 143 $this->capture = false; 144 } 145 } 146 147 /** 148 * Add an item to the TOC 149 * 150 * @param string $id the hash link 151 * @param string $text the text to display 152 * @param int $level the nesting level 153 */ 154 public function toc_additem($id, $text, $level) 155 { 156 global $conf; 157 158 //only add items within configured levels 159 if ($level >= $conf['toptoclevel'] && $level <= $conf['maxtoclevel']) { 160 // the TOC is one of our standard ul list arrays ;-) 161 $this->meta['description']['tableofcontents'][] = [ 162 'hid' => $id, 163 'title' => $text, 164 'type' => 'ul', 165 'level' => $level - $conf['toptoclevel'] + 1 166 ]; 167 } 168 } 169 170 /** 171 * Render a heading 172 * 173 * @param string $text the text to display 174 * @param int $level header level 175 * @param int $pos byte position in the original source 176 */ 177 public function header($text, $level, $pos) 178 { 179 if (!isset($this->meta['title'])) { 180 $this->meta['title'] = $text; 181 } 182 183 // add the header to the TOC 184 $hid = $this->_headerToLink($text, true); 185 $this->toc_additem($hid, $text, $level); 186 187 // add to summary 188 $this->cdata(DOKU_LF . $text . DOKU_LF); 189 } 190 191 /** 192 * Open a paragraph 193 */ 194 public function p_open() 195 { 196 $this->cdata(DOKU_LF); 197 } 198 199 /** 200 * Close a paragraph 201 */ 202 public function p_close() 203 { 204 $this->cdata(DOKU_LF); 205 } 206 207 /** 208 * Create a line break 209 */ 210 public function linebreak() 211 { 212 $this->cdata(DOKU_LF); 213 } 214 215 /** 216 * Create a horizontal line 217 */ 218 public function hr() 219 { 220 $this->cdata(DOKU_LF . '----------' . DOKU_LF); 221 } 222 223 /** 224 * Callback for footnote start syntax 225 * 226 * All following content will go to the footnote instead of 227 * the document. To achieve this the previous rendered content 228 * is moved to $store and $doc is cleared 229 * 230 * @author Andreas Gohr <andi@splitbrain.org> 231 */ 232 public function footnote_open() 233 { 234 if ($this->capture) { 235 // move current content to store 236 // this is required to ensure safe behaviour of plugins accessed within footnotes 237 $this->store = $this->doc; 238 $this->doc = ''; 239 240 // disable capturing 241 $this->capturing = false; 242 } 243 } 244 245 /** 246 * Callback for footnote end syntax 247 * 248 * All content rendered whilst within footnote syntax mode is discarded, 249 * the previously rendered content is restored and capturing is re-enabled. 250 * 251 * @author Andreas Gohr 252 */ 253 public function footnote_close() 254 { 255 if ($this->capture) { 256 // re-enable capturing 257 $this->capturing = true; 258 // restore previously rendered content 259 $this->doc = $this->store; 260 $this->store = ''; 261 } 262 } 263 264 /** 265 * Open an unordered list 266 */ 267 public function listu_open() 268 { 269 $this->cdata(DOKU_LF); 270 } 271 272 /** 273 * Open an ordered list 274 */ 275 public function listo_open() 276 { 277 $this->cdata(DOKU_LF); 278 } 279 280 /** 281 * Open a list item 282 * 283 * @param int $level the nesting level 284 * @param bool $node true when a node; false when a leaf 285 */ 286 public function listitem_open($level, $node = false) 287 { 288 $this->cdata(str_repeat(DOKU_TAB, $level) . '* '); 289 } 290 291 /** 292 * Close a list item 293 */ 294 public function listitem_close() 295 { 296 $this->cdata(DOKU_LF); 297 } 298 299 /** 300 * Output preformatted text 301 * 302 * @param string $text 303 */ 304 public function preformatted($text) 305 { 306 $this->cdata($text); 307 } 308 309 /** 310 * Start a block quote 311 */ 312 public function quote_open() 313 { 314 $this->cdata(DOKU_LF . DOKU_TAB . '"'); 315 } 316 317 /** 318 * Stop a block quote 319 */ 320 public function quote_close() 321 { 322 $this->cdata('"' . DOKU_LF); 323 } 324 325 /** 326 * Display text as file content, optionally syntax highlighted 327 * 328 * @param string $text text to show 329 * @param string $lang programming language to use for syntax highlighting 330 * @param string $file file path label 331 */ 332 public function file($text, $lang = null, $file = null) 333 { 334 $this->cdata(DOKU_LF . $text . DOKU_LF); 335 } 336 337 /** 338 * Display text as code content, optionally syntax highlighted 339 * 340 * @param string $text text to show 341 * @param string $language programming language to use for syntax highlighting 342 * @param string $file file path label 343 */ 344 public function code($text, $language = null, $file = null) 345 { 346 $this->cdata(DOKU_LF . $text . DOKU_LF); 347 } 348 349 /** 350 * Format an acronym 351 * 352 * Uses $this->acronyms 353 * 354 * @param string $acronym 355 */ 356 public function acronym($acronym) 357 { 358 $this->cdata($acronym); 359 } 360 361 /** 362 * Format a smiley 363 * 364 * Uses $this->smiley 365 * 366 * @param string $smiley 367 */ 368 public function smiley($smiley) 369 { 370 $this->cdata($smiley); 371 } 372 373 /** 374 * Format an entity 375 * 376 * Entities are basically small text replacements 377 * 378 * Uses $this->entities 379 * 380 * @param string $entity 381 */ 382 public function entity($entity) 383 { 384 $this->cdata($entity); 385 } 386 387 /** 388 * Typographically format a multiply sign 389 * 390 * Example: ($x=640, $y=480) should result in "640×480" 391 * 392 * @param string|int $x first value 393 * @param string|int $y second value 394 */ 395 public function multiplyentity($x, $y) 396 { 397 $this->cdata($x . '×' . $y); 398 } 399 400 /** 401 * Render an opening single quote char (language specific) 402 */ 403 public function singlequoteopening() 404 { 405 global $lang; 406 $this->cdata($lang['singlequoteopening']); 407 } 408 409 /** 410 * Render a closing single quote char (language specific) 411 */ 412 public function singlequoteclosing() 413 { 414 global $lang; 415 $this->cdata($lang['singlequoteclosing']); 416 } 417 418 /** 419 * Render an apostrophe char (language specific) 420 */ 421 public function apostrophe() 422 { 423 global $lang; 424 $this->cdata($lang['apostrophe']); 425 } 426 427 /** 428 * Render an opening double quote char (language specific) 429 */ 430 public function doublequoteopening() 431 { 432 global $lang; 433 $this->cdata($lang['doublequoteopening']); 434 } 435 436 /** 437 * Render an closinging double quote char (language specific) 438 */ 439 public function doublequoteclosing() 440 { 441 global $lang; 442 $this->cdata($lang['doublequoteclosing']); 443 } 444 445 /** 446 * Render a CamelCase link 447 * 448 * @param string $link The link name 449 * @see http://en.wikipedia.org/wiki/CamelCase 450 */ 451 public function camelcaselink($link) 452 { 453 $this->internallink($link, $link); 454 } 455 456 /** 457 * Render a page local link 458 * 459 * @param string $hash hash link identifier 460 * @param string $name name for the link 461 */ 462 public function locallink($hash, $name = null) 463 { 464 if (is_array($name)) { 465 $this->_firstimage($name['src']); 466 if ($name['type'] == 'internalmedia') { 467 $this->_recordMediaUsage($name['src']); 468 } 469 } 470 } 471 472 /** 473 * keep track of internal links in $this->meta['relation']['references'] 474 * 475 * @param string $id page ID to link to. eg. 'wiki:syntax' 476 * @param string|array|null $name name for the link, array for media file 477 */ 478 public function internallink($id, $name = null) 479 { 480 global $ID; 481 482 if (is_array($name)) { 483 $this->_firstimage($name['src']); 484 if ($name['type'] == 'internalmedia') { 485 $this->_recordMediaUsage($name['src']); 486 } 487 } 488 489 $parts = explode('?', $id, 2); 490 if (count($parts) === 2) { 491 $id = $parts[0]; 492 } 493 494 $default = $this->_simpleTitle($id); 495 496 // first resolve and clean up the $id 497 $resolver = new PageResolver($ID); 498 $id = $resolver->resolveId($id); 499 [$page] = sexplode('#', $id, 2); 500 501 // set metadata 502 $this->meta['relation']['references'][$page] = page_exists($page); 503 // $data = array('relation' => array('isreferencedby' => array($ID => true))); 504 // p_set_metadata($id, $data); 505 506 // add link title to summary 507 if ($this->capture) { 508 $name = $this->_getLinkTitle($name, $default, $id); 509 $this->doc .= $name; 510 } 511 } 512 513 /** 514 * Render an external link 515 * 516 * @param string $url full URL with scheme 517 * @param string|array|null $name name for the link, array for media file 518 */ 519 public function externallink($url, $name = null) 520 { 521 if (is_array($name)) { 522 $this->_firstimage($name['src']); 523 if ($name['type'] == 'internalmedia') { 524 $this->_recordMediaUsage($name['src']); 525 } 526 } 527 528 if ($this->capture) { 529 $this->doc .= $this->_getLinkTitle($name, '<' . $url . '>'); 530 } 531 } 532 533 /** 534 * Render an interwiki link 535 * 536 * You may want to use $this->_resolveInterWiki() here 537 * 538 * @param string $match original link - probably not much use 539 * @param string|array $name name for the link, array for media file 540 * @param string $wikiName indentifier (shortcut) for the remote wiki 541 * @param string $wikiUri the fragment parsed from the original link 542 */ 543 public function interwikilink($match, $name, $wikiName, $wikiUri) 544 { 545 if (is_array($name)) { 546 $this->_firstimage($name['src']); 547 if ($name['type'] == 'internalmedia') { 548 $this->_recordMediaUsage($name['src']); 549 } 550 } 551 552 if ($this->capture) { 553 [$wikiUri] = explode('#', $wikiUri, 2); 554 $name = $this->_getLinkTitle($name, $wikiUri); 555 $this->doc .= $name; 556 } 557 } 558 559 /** 560 * Link to windows share 561 * 562 * @param string $url the link 563 * @param string|array $name name for the link, array for media file 564 */ 565 public function windowssharelink($url, $name = null) 566 { 567 if (is_array($name)) { 568 $this->_firstimage($name['src']); 569 if ($name['type'] == 'internalmedia') { 570 $this->_recordMediaUsage($name['src']); 571 } 572 } 573 574 if ($this->capture) { 575 if ($name) { 576 $this->doc .= $name; 577 } else { 578 $this->doc .= '<' . $url . '>'; 579 } 580 } 581 } 582 583 /** 584 * Render a linked E-Mail Address 585 * 586 * Should honor $conf['mailguard'] setting 587 * 588 * @param string $address Email-Address 589 * @param string|array $name name for the link, array for media file 590 */ 591 public function emaillink($address, $name = null) 592 { 593 if (is_array($name)) { 594 $this->_firstimage($name['src']); 595 if ($name['type'] == 'internalmedia') { 596 $this->_recordMediaUsage($name['src']); 597 } 598 } 599 600 if ($this->capture) { 601 if ($name) { 602 $this->doc .= $name; 603 } else { 604 $this->doc .= '<' . $address . '>'; 605 } 606 } 607 } 608 609 /** 610 * Render an internal media file 611 * 612 * @param string $src media ID 613 * @param string $title descriptive text 614 * @param string $align left|center|right 615 * @param int $width width of media in pixel 616 * @param int $height height of media in pixel 617 * @param string $cache cache|recache|nocache 618 * @param string $linking linkonly|detail|nolink 619 */ 620 public function internalmedia( 621 $src, 622 $title = null, 623 $align = null, 624 $width = null, 625 $height = null, 626 $cache = null, 627 $linking = null 628 ) { 629 if ($this->capture && $title) { 630 $this->doc .= '[' . $title . ']'; 631 } 632 $this->_firstimage($src); 633 $this->_recordMediaUsage($src); 634 } 635 636 /** 637 * Render an external media file 638 * 639 * @param string $src full media URL 640 * @param string $title descriptive text 641 * @param string $align left|center|right 642 * @param int $width width of media in pixel 643 * @param int $height height of media in pixel 644 * @param string $cache cache|recache|nocache 645 * @param string $linking linkonly|detail|nolink 646 */ 647 public function externalmedia( 648 $src, 649 $title = null, 650 $align = null, 651 $width = null, 652 $height = null, 653 $cache = null, 654 $linking = null 655 ) { 656 if ($this->capture && $title) { 657 $this->doc .= '[' . $title . ']'; 658 } 659 $this->_firstimage($src); 660 } 661 662 /** 663 * Render the output of an RSS feed 664 * 665 * @param string $url URL of the feed 666 * @param array $params Finetuning of the output 667 */ 668 public function rss($url, $params) 669 { 670 $this->meta['relation']['haspart'][$url] = true; 671 672 $this->meta['date']['valid']['age'] = 673 isset($this->meta['date']['valid']['age']) ? 674 min($this->meta['date']['valid']['age'], $params['refresh']) : 675 $params['refresh']; 676 } 677 678 #region Utils 679 680 /** 681 * Removes any Namespace from the given name but keeps 682 * casing and special chars 683 * 684 * @param string $name 685 * 686 * @return mixed|string 687 * @author Andreas Gohr <andi@splitbrain.org> 688 * 689 */ 690 public function _simpleTitle($name) 691 { 692 global $conf; 693 694 if (is_array($name)) { 695 return ''; 696 } 697 698 if ($conf['useslash']) { 699 $nssep = '[:;/]'; 700 } else { 701 $nssep = '[:;]'; 702 } 703 $name = preg_replace('!.*' . $nssep . '!', '', $name); 704 //if there is a hash we use the anchor name only 705 $name = preg_replace('!.*#!', '', $name); 706 return $name; 707 } 708 709 /** 710 * Construct a title and handle images in titles 711 * 712 * @param string|array|null $title either string title or media array 713 * @param string $default default title if nothing else is found 714 * @param null|string $id linked page id (used to extract title from first heading) 715 * @return string title text 716 * @author Harry Fuecks <hfuecks@gmail.com> 717 */ 718 public function _getLinkTitle($title, $default, $id = null) 719 { 720 if (is_array($title)) { 721 if ($title['title']) { 722 return '[' . $title['title'] . ']'; 723 } else { 724 return $default; 725 } 726 } elseif (is_null($title) || trim($title) == '') { 727 if (useHeading('content') && $id) { 728 $heading = p_get_first_heading($id, METADATA_DONT_RENDER); 729 if ($heading) { 730 return $heading; 731 } 732 } 733 return $default; 734 } else { 735 return $title; 736 } 737 } 738 739 /** 740 * Remember first image 741 * 742 * @param string $src image URL or ID 743 */ 744 protected function _firstimage($src) 745 { 746 global $ID; 747 748 if ($this->firstimage) { 749 return; 750 } 751 752 [$src] = explode('#', $src, 2); 753 if (!media_isexternal($src)) { 754 $src = (new MediaResolver($ID))->resolveId($src); 755 } 756 if (preg_match('/.(jpe?g|gif|png|webp|svg)$/i', $src)) { 757 $this->firstimage = $src; 758 } 759 } 760 761 /** 762 * Store list of used media files in metadata 763 * 764 * @param string $src media ID 765 */ 766 protected function _recordMediaUsage($src) 767 { 768 global $ID; 769 770 [$src] = explode('#', $src, 2); 771 if (media_isexternal($src)) { 772 return; 773 } 774 $src = (new MediaResolver($ID))->resolveId($src); 775 $file = mediaFN($src); 776 $this->meta['relation']['media'][$src] = file_exists($file); 777 } 778 779 #endregion 780} 781 782//Setup VIM: ex: et ts=4 : 783