xref: /plugin/parserfunctions/helper.php (revision 7c8f4ff3f5d7ff2d4e53b772530f9e5265fcf961)
1d0ddb69dSnerun<?php
2d0ddb69dSnerun/**
3d0ddb69dSnerun * DokuWiki Plugin parserfunctions (Helper Component)
4d0ddb69dSnerun *
5d0ddb69dSnerun * @license  GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6d0ddb69dSnerun * @author   Daniel "Nerun" Rodrigues <danieldiasr@gmail.com>
7d0ddb69dSnerun * @created  Tue, 01 jul 2025 15:06:42 -0300
8d0ddb69dSnerun */
9d0ddb69dSnerun
10d0ddb69dSnerunif (!defined('DOKU_INC')) die();
11d0ddb69dSnerun
12d0ddb69dSnerunclass helper_plugin_parserfunctions extends DokuWiki_Plugin {
13d0ddb69dSnerun
14d0ddb69dSnerun    /**
15d0ddb69dSnerun     * Processes raw function input into normalized parameters, handling pipe escapes
16d0ddb69dSnerun     *
17d0ddb69dSnerun     * Safely splits the input string by pipes (`|`) while respecting escaped pipes (`%%|%%`).
18d0ddb69dSnerun     * Performs whitespace trimming on all resulting parameters. This enables DokuWiki's
19d0ddb69dSnerun     * standard pipe syntax while supporting escaped pipes in parameter values.
20d0ddb69dSnerun     *
21d0ddb69dSnerun     * @param string $input The raw function input between delimiters (e.g. "a|b%%|%%c|d")
22d0ddb69dSnerun     *
23d0ddb69dSnerun     * @return array Normalized parameters with:
24d0ddb69dSnerun     *   - Escaped pipes restored (`%%TEMP_PIPE%%` → `%%|%%`)
25d0ddb69dSnerun     *   - Whitespace trimmed from both ends
26d0ddb69dSnerun     *   - Empty strings preserved as valid parameters
27d0ddb69dSnerun     *
28d0ddb69dSnerun     * @example Basic usage:
29d0ddb69dSnerun     *   parseParameters("a|b|c") → ["a", "b", "c"]
30d0ddb69dSnerun     *
31d0ddb69dSnerun     * @example With escaped pipe:
32d0ddb69dSnerun     *   parseParameters("a|b%%|%%c|d") → ["a", "b|c", "d"]
33d0ddb69dSnerun     *
34d0ddb69dSnerun     * @example With whitespace:
35d0ddb69dSnerun     *   parseParameters(" a |  b  ") → ["a", "b"]
36d0ddb69dSnerun     *
37d0ddb69dSnerun     * @example Empty parameters:
38d0ddb69dSnerun     *   parseParameters("a||b") → ["a", "", "b"]
39d0ddb69dSnerun     *
40d0ddb69dSnerun     * @note Preserves DokuWiki's standard %% escape syntax
41d0ddb69dSnerun     * @note Empty strings are valid parameters (unlike array_filter)
42d0ddb69dSnerun     * @note Trims only outer whitespace (inner spaces remain)
43d0ddb69dSnerun     */
44d0ddb69dSnerun    public function parseParameters($input) {
45d0ddb69dSnerun        // 1) Replace escaped pipes with temporary marker
46d0ddb69dSnerun        $input = str_replace('%%|%%', '%%TEMP_PIPE%%', $input);
47d0ddb69dSnerun
48d0ddb69dSnerun        // 2) Split by unescaped pipes
49d0ddb69dSnerun        $params = explode('|', $input);
50d0ddb69dSnerun
51d0ddb69dSnerun        // 3) Restore escaped pipes
52d0ddb69dSnerun        $params = array_map(function($param) {
53d0ddb69dSnerun            return str_replace('%%TEMP_PIPE%%', '%%|%%', $param);
54d0ddb69dSnerun        }, $params);
55d0ddb69dSnerun
56d0ddb69dSnerun        // 4) Remove whitespace
57d0ddb69dSnerun        return array_map('trim', $params);
58d0ddb69dSnerun    }
59d0ddb69dSnerun
60d0ddb69dSnerun    /**
61d0ddb69dSnerun     * Parses parameters for a SWITCH parser function and structures them for evaluation
62d0ddb69dSnerun     *
63d0ddb69dSnerun     * Processes the parameters into cases, test value, and default value, with support for:
64d0ddb69dSnerun     * - Explicit value cases (`case = value`)
65d0ddb69dSnerun     * - Fallthrough behavior (cases without values inherit the last defined value)
66d0ddb69dSnerun     * - Both explicit (`#default = value`) and implicit default values (last parameter)
67d0ddb69dSnerun     * - Whitespace normalization (trim) for all keys and values
68d0ddb69dSnerun     * - Escaped equals signs (`%%=%%`) in values
69d0ddb69dSnerun     *
70d0ddb69dSnerun     * @param array $params The raw parameters from the parser function call:
71d0ddb69dSnerun     *   - First element: The test value to compare against cases
72d0ddb69dSnerun     *   - Subsequent elements: Cases in format "case = value" or fallthrough/default markers
73d0ddb69dSnerun     *
74d0ddb69dSnerun     * @return array Structured data with:
75d0ddb69dSnerun     *   - 'cases': Associative array of [case => value] pairs
76d0ddb69dSnerun     *   - 'test': Normalized test value (with whitespace trimmed)
77d0ddb69dSnerun     *   - 'default': The default value (either explicit #default or last parameter)
78d0ddb69dSnerun     *
79d0ddb69dSnerun     * @example For input [" test ", "a=1", "b", "c=3", "#default=final"]
80d0ddb69dSnerun     *   Returns:
81d0ddb69dSnerun     *     [
82d0ddb69dSnerun     *       'cases' => ['a' => '1', 'b' => '3', 'c' => '3'],
83d0ddb69dSnerun     *       'test' => 'test',
84d0ddb69dSnerun     *       'default' => 'final'
85d0ddb69dSnerun     *     ]
86d0ddb69dSnerun     *
87d0ddb69dSnerun     * @example Fallthrough behavior:
88d0ddb69dSnerun     *   ["val", "a=1", "b", "c=2"] produces:
89d0ddb69dSnerun     *     [
90d0ddb69dSnerun     *       'cases' => ['a' => '1', 'b' => '1', 'c' => '2'],
91d0ddb69dSnerun     *       'test' => 'val',
92d0ddb69dSnerun     *       'default' => '2'
93d0ddb69dSnerun     *     ]
94d0ddb69dSnerun     *
95d0ddb69dSnerun     * @note Escaped equals signs (`%%=%%`) in values are preserved
96d0ddb69dSnerun     * @note All case keys and test values are trimmed of whitespace
97d0ddb69dSnerun     * @note Empty strings are valid as both test values and case values
98d0ddb69dSnerun     */
99d0ddb69dSnerun    public function parseSwitchCases($params) {
100d0ddb69dSnerun        $cases = [];
101d0ddb69dSnerun        $default = null;
102d0ddb69dSnerun        $testString = null;
103d0ddb69dSnerun        $lastValue = null;
104d0ddb69dSnerun
105d0ddb69dSnerun        foreach ($params as $param) {
106d0ddb69dSnerun            $param = str_replace('%%=%%', '%%TEMP_EQUAL%%', $param);
107d0ddb69dSnerun            $parts = explode('=', $param, 2);
108d0ddb69dSnerun            $parts = array_map('trim', $parts);
109d0ddb69dSnerun
110d0ddb69dSnerun            if (count($parts) === 2) {
111d0ddb69dSnerun                // Case with explicit value (case = value)
112d0ddb69dSnerun                $parts[1] = str_replace('%%TEMP_EQUAL%%', '%%=%%', $parts[1]);
113d0ddb69dSnerun                $cases[$parts[0]] = $parts[1];
114d0ddb69dSnerun                $lastValue = $parts[1];
115d0ddb69dSnerun            } else {
116d0ddb69dSnerun                // Case without explicit value (fallthrough or default)
117d0ddb69dSnerun                $parts[0] = str_replace('%%TEMP_EQUAL%%', '%%=%%', $parts[0]);
118d0ddb69dSnerun
119d0ddb69dSnerun                if ($testString === null) {
120d0ddb69dSnerun                    $testString = trim($parts[0]); // First parameter is the test value
121d0ddb69dSnerun                } elseif (trim($parts[0]) === '#default') {
122d0ddb69dSnerun                    $default = $lastValue; // Explicit default
123d0ddb69dSnerun                } else {
124d0ddb69dSnerun                    $cases[trim($parts[0])] = $lastValue; // Fallthrough - uses last defined value
125d0ddb69dSnerun                }
126d0ddb69dSnerun            }
127d0ddb69dSnerun        }
128d0ddb69dSnerun
129d0ddb69dSnerun        return [
130d0ddb69dSnerun            'cases' => $cases,
131d0ddb69dSnerun            'test' => $testString,
132d0ddb69dSnerun            'default' => $default ?? $lastValue // Implicit default is the last value
133d0ddb69dSnerun        ];
134d0ddb69dSnerun    }
135d0ddb69dSnerun
136d0ddb69dSnerun    /**
137fb66d8abSnerun     * Checks for the existence of a folder (namespace) or a file (media or page)
138d0ddb69dSnerun     *
139fb66d8abSnerun     * Accepts:
140fb66d8abSnerun     * - Absolute or relative filesystem paths
141fb66d8abSnerun     * - DokuWiki page/media IDs (e.g. "wiki:start", "wiki:image.png")
142fb66d8abSnerun     * - DokuWiki namespaces (must end with a colon, e.g. "wiki:")
143fb66d8abSnerun     *
144fb66d8abSnerun     * @param string $target The identifier or path to check
145fb66d8abSnerun     * @return bool True if it exists (file, page, media, or namespace), false otherwise
146d0ddb69dSnerun     */
147d0ddb69dSnerun    public function checkExistence($target) {
148fb66d8abSnerun        // Normalize spaces around ':', transform "wiki : help" → "wiki:help"
149fb66d8abSnerun        $target = preg_replace('/\s*:\s*/', ':', $target);
150fb66d8abSnerun
151fb66d8abSnerun        // If it is a real absolute or relative path, test as file or folder
152fb66d8abSnerun        if (file_exists($target)) {
153fb66d8abSnerun            return true;
154d0ddb69dSnerun        }
155d0ddb69dSnerun
156fb66d8abSnerun        // If path started with '/', try as relative to DOKU_INC by removing '/'
157fb66d8abSnerun        if (strlen($target) > 0 && $target[0] === '/') {
158fb66d8abSnerun            $relativePath = ltrim($target, '/');
159fb66d8abSnerun            $fullPath = DOKU_INC . $relativePath;
160fb66d8abSnerun            if (file_exists($fullPath)) {
161fb66d8abSnerun                return true;
162fb66d8abSnerun            }
163fb66d8abSnerun        }
164fb66d8abSnerun
165fb66d8abSnerun        // Try as DokuWiki page
166fb66d8abSnerun        if (page_exists($target)) {
167fb66d8abSnerun            return true;
168fb66d8abSnerun        }
169fb66d8abSnerun
170fb66d8abSnerun        // Try as DokuWiki media
171fb66d8abSnerun        if (file_exists(mediaFN($target))) {
172fb66d8abSnerun            return true;
173fb66d8abSnerun        }
174fb66d8abSnerun
175fb66d8abSnerun        // Try as namespace (directory inside data/pages/)
176fb66d8abSnerun        $namespacePath = str_replace(':', '/', $target);
177fb66d8abSnerun        $namespaceDir = DOKU_INC . 'data/pages/' . $namespacePath;
178fb66d8abSnerun        if (is_dir($namespaceDir)) {
179fb66d8abSnerun            return true;
180fb66d8abSnerun        }
181fb66d8abSnerun
182fb66d8abSnerun        return false;
183d0ddb69dSnerun    }
184d0ddb69dSnerun
185d0ddb69dSnerun    /**
186d0ddb69dSnerun     * Escape sequence handling (for backwards compatibility)
187d0ddb69dSnerun     *
188d0ddb69dSnerun     * To add more escapes, please refer to:
189d0ddb69dSnerun     * https://www.freeformatter.com/html-entities.html
190d0ddb69dSnerun     *
191d0ddb69dSnerun     * Before 2025-01-18, escape sequences had to use "&&num;NUMBER;" instead of
192d0ddb69dSnerun     * "&#;NUMBER;", because "#" was not escaped.
193d0ddb69dSnerun     *
194d0ddb69dSnerun     * After 2025-01-18, the "#" can be typed directly, and does not need to be
195d0ddb69dSnerun     * escaped. So use the normal spelling for HTML entity codes ("&#61;"
196d0ddb69dSnerun     * instead of "&&num;61;") when adding NEW escapes.
197d0ddb69dSnerun     *
198d0ddb69dSnerun     * Additionally, after 2025-01-18, '=', '|', '{' and '}' signs can be
199d0ddb69dSnerun     * escaped only by wrapping them in '%%', following the standard DokuWiki
200d0ddb69dSnerun     * syntax. So, the escapes below are DEPRECATED, but kept for backwards
201d0ddb69dSnerun     * compatibility.
202d0ddb69dSnerun     *
203d0ddb69dSnerun     */
204d0ddb69dSnerun    public function processEscapes($text) {
205d0ddb69dSnerun        // DEPRECATED, but kept for backwards compatibility:
206d0ddb69dSnerun        $escapes = [
207d0ddb69dSnerun            "&&num;61;"  => "=",
208d0ddb69dSnerun            "&&num;123;" => "%%{%%",
209d0ddb69dSnerun            "&&num;124;" => "|",
210d0ddb69dSnerun            "&&num;125;" => "%%}%%",
211d0ddb69dSnerun            "&num;"      => "#" // Always leave this as the last element!
212d0ddb69dSnerun        ];
213d0ddb69dSnerun
214d0ddb69dSnerun        foreach ($escapes as $key => $value) {
215d0ddb69dSnerun            $text = str_replace($key, $value, $text);
216d0ddb69dSnerun        }
217d0ddb69dSnerun
218d0ddb69dSnerun        return $text;
219d0ddb69dSnerun    }
220d0ddb69dSnerun
221d0ddb69dSnerun    /**
222d0ddb69dSnerun     * Format error messages consistently
223d0ddb69dSnerun     */
224d0ddb69dSnerun    public function formatError($type, $function, $messageKey) {
225fb66d8abSnerun        $wrapPluginExists = file_exists(DOKU_INC . 'lib/plugins/wrap');
226d0ddb69dSnerun
2278bd4c45fSnerun        $errorMsg = '**' . $this->getLang('error') . ' ' . $function . ': '
228d0ddb69dSnerun                    . $this->getLang($messageKey) . '**';
229d0ddb69dSnerun
230d0ddb69dSnerun        if ($wrapPluginExists) {
231d0ddb69dSnerun            return "<wrap $type>$errorMsg</wrap>";
232d0ddb69dSnerun        }
233d0ddb69dSnerun
234d0ddb69dSnerun        return $errorMsg;
235d0ddb69dSnerun    }
236bca4461fSnerun
237bca4461fSnerun    /**
238bca4461fSnerun     * Evaluates a mathematical expression consistently
239bca4461fSnerun     */
240bca4461fSnerun    public function evaluateMathExpression($expr) {
241bca4461fSnerun        $funcName = 'expr';
242bca4461fSnerun        $expr = trim($expr);
243bca4461fSnerun
244bca4461fSnerun        // Rejects characters outside the permitted set
245*7c8f4ff3Snerun        if (!preg_match('/^(
246*7c8f4ff3Snerun                            \s*|
247*7c8f4ff3Snerun                            \b(?:and|or|xor|not)\b|                # Reserved words first
248*7c8f4ff3Snerun                            ==|!=|<=|>=|<|>|                       # Comparisons
249*7c8f4ff3Snerun                            \+|\-|\*|\/|%|                         # Arithmetic
250*7c8f4ff3Snerun                            \(|\)|                                 # Parentheses
251*7c8f4ff3Snerun                            &&|\|\||!|                             # Symbolic logics
252*7c8f4ff3Snerun                            [0-9]+(\.[0-9]+)?([eE][\+\-]?[0-9]+)?  # Numbers with period and exponent
253*7c8f4ff3Snerun                        )+$/ix'
254*7c8f4ff3Snerun                        , $expr)) {
255bca4461fSnerun            return $this->formatError('alert', $funcName, 'invalid_expression');
256bca4461fSnerun        }
257bca4461fSnerun
258bca4461fSnerun        try {
259*7c8f4ff3Snerun            $expr = preg_replace('/\bnot\b/i', '!', $expr);
260bca4461fSnerun            // Simple evaluation
261bca4461fSnerun            $result = eval('return (' . $expr . ');');
262*7c8f4ff3Snerun
263bca4461fSnerun            if (!is_numeric($result) || is_infinite($result) || is_nan($result)) {
264*7c8f4ff3Snerun                if (is_bool($result)) {
265*7c8f4ff3Snerun                    return $result ? 1 : 0;
266*7c8f4ff3Snerun                } else {
267bca4461fSnerun                    return $this->formatError('alert', $funcName, 'undefined_result');
268bca4461fSnerun                }
269*7c8f4ff3Snerun            }
270*7c8f4ff3Snerun
271bca4461fSnerun            return $result;
272bca4461fSnerun        } catch (Throwable $e) {
273bca4461fSnerun            return $this->formatError('alert', $funcName, 'evaluation_error');
274bca4461fSnerun        }
275bca4461fSnerun    }
276d0ddb69dSnerun}
277d0ddb69dSnerun
278