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