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