xref: /dokuwiki/inc/parser/metadata.php (revision d13b57795640cf25d2bad48571dd93b662332b97)
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