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'] .= '&amp;';
451            if (is_array($search)) {
452                $search = array_map('rawurlencode', $search);
453                $link['url'] .= 's[]=' . join('&amp;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?>