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