Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
171 / 173
83.33% covered (success)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
98.84% covered (success)
98.84%
171 / 173
83.33% covered (success)
83.33%
10 / 12
27
0.00% covered (danger)
0.00%
0 / 1
 isLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLikeOnPMFDate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 makeShorterText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveMarkers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 chopString
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setHighlightedString
100.00% covered (success)
100.00%
106 / 106
100.00% covered (success)
100.00%
1 / 1
2
 highlightNoLinks
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 isForbiddenElement
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 parseUrl
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getHostFromUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 moveToTop
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 formatBytes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Utilities - Functions and Classes common to the whole phpMyFAQ architecture.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Matteo Scaramuccia <matteo@phpmyfaq.de>
13 * @copyright 2005-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2005-11-01
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ;
22
23/**
24 * Class Utils
25 *
26 * @package phpMyFAQ
27 */
28class Utils
29{
30    /**
31     * Check if a given string could be a language.
32     *
33     * @param string $language Language
34     */
35    public static function isLanguage(string $language): bool
36    {
37        return (bool) preg_match(pattern: '/^[a-zA-Z\-]+$/', subject: $language);
38    }
39
40    /**
41     * Checks if a date is a phpMyFAQ valid date.
42     *
43     * @param string $date Date
44     */
45    public static function isLikeOnPMFDate(string $date): bool
46    {
47        // Test if the passed string is in the format: %YYYYMMDDhhmmss%
48        $dateToTest = $date;
49        // Suppress first occurrences of '%'
50        if (str_starts_with($dateToTest, '%')) {
51            $dateToTest = substr($dateToTest, offset: 1);
52        }
53
54        // Suppress last occurrences of '%'
55        if (str_ends_with($dateToTest, needle: '%')) {
56            $dateToTest = substr(string: $dateToTest, offset: 0, length: strlen($dateToTest) - 1);
57        }
58
59        // PMF date consists of numbers only: YYYYMMDDhhmmss
60        return is_numeric($dateToTest);
61    }
62
63    /**
64     * Shortens a string for a given number of words.
65     *
66     * @param string $string String
67     * @param int    $characters Characters
68     * @todo This function doesn't work with Chinese, Japanese, Korean and Thai because they don't have spaces as word
69     *       delimiters
70     */
71    public static function makeShorterText(string $string, int $characters): string
72    {
73        $condensed = Strings::preg_replace(pattern: '/\s+/u', replacement: ' ', subject: $string);
74        $string = is_string($condensed) ? $condensed : $string;
75        $arrStr = explode(separator: ' ', string: $string);
76
77        if (count($arrStr) > $characters) {
78            return implode(separator: ' ', array: array_slice(array: $arrStr, offset: 0, length: $characters)) . ' ...';
79        }
80
81        return $string;
82    }
83
84    /**
85     * Resolves the phpMyFAQ markers like e.g. %sitename%.
86     *
87     * @param string $text Text contains phpMyFAQ markers
88     */
89    public static function resolveMarkers(string $text, Configuration $configuration): string
90    {
91        // Available markers: key and resolving value
92        $markers = [
93            '%sitename%' => $configuration->getTitle(),
94        ];
95
96        // Resolve any known pattern
97        return str_replace(array_keys($markers), array_values($markers), $text);
98    }
99
100    /**
101     * This method chops a string.
102     *
103     * @param string $string String to chop
104     * @param int    $words Number of words
105     */
106    public static function chopString(string $string, int $words): string
107    {
108        $str = '';
109        $pieces = explode(separator: ' ', string: $string);
110        $num = count($pieces);
111        if ($words > $num) {
112            $words = $num;
113        }
114
115        for ($i = 0; $i < $words; ++$i) {
116            $str .= $pieces[$i] . ' ';
117        }
118
119        return $str;
120    }
121
122    /**
123     * Adds a highlighted word to a string.
124     *
125     * @param string $string String
126     * @param string $highlight Given word for highlighting
127     */
128    public static function setHighlightedString(string $string, string $highlight): string
129    {
130        $attributes = [
131            'href',
132            'src',
133            'title',
134            'alt',
135            'class',
136            'style',
137            'id',
138            'name',
139            'face',
140            'size',
141            'dir',
142            'rel',
143            'rev',
144            'role',
145            'onmouseenter',
146            'onmouseleave',
147            'onafterprint',
148            'onbeforeprint',
149            'onbeforeunload',
150            'onhashchange',
151            'onmessage',
152            'onoffline',
153            'ononline',
154            'onpopstate',
155            'onpagehide',
156            'onpageshow',
157            'onresize',
158            'onunload',
159            'ondevicemotion',
160            'ondeviceorientation',
161            'onabort',
162            'onblur',
163            'oncanplay',
164            'oncanplaythrough',
165            'onchange',
166            'onclick',
167            'oncontextmenu',
168            'ondblclick',
169            'ondrag',
170            'ondragend',
171            'ondragenter',
172            'ondragleave',
173            'ondragover',
174            'ondragstart',
175            'ondrop',
176            'ondurationchange',
177            'onemptied',
178            'onended',
179            'onerror',
180            'onfocus',
181            'oninput',
182            'oninvalid',
183            'onkeydown',
184            'onkeypress',
185            'onkeyup',
186            'onload',
187            'onloadeddata',
188            'onloadedmetadata',
189            'onloadstart',
190            'onmousedown',
191            'onmousemove',
192            'onmouseout',
193            'onmouseover',
194            'onmouseup',
195            'onmozfullscreenchange',
196            'onmozfullscreenerror',
197            'onpause',
198            'onplay',
199            'onplaying',
200            'onprogress',
201            'onratechange',
202            'onreset',
203            'onscroll',
204            'onseeked',
205            'onseeking',
206            'onselect',
207            'onshow',
208            'onstalled',
209            'onsubmit',
210            'onsuspend',
211            'ontimeupdate',
212            'onvolumechange',
213            'onwaiting',
214            'oncopy',
215            'oncut',
216            'onpaste',
217            'onbeforescriptexecute',
218            'onafterscriptexecute',
219        ];
220
221        $highlighted = Strings::preg_replace_callback(
222            '/('
223            . $highlight
224            . '="[^"]*")|'
225            . '(('
226            . implode(separator: '|', array: $attributes)
227            . ')="[^"]*'
228            . $highlight
229            . '[^"]*")|'
230            . '('
231            . $highlight
232            . ')/mis',
233            ['phpMyFAQ\Utils', 'highlightNoLinks'],
234            $string,
235        );
236
237        return is_string($highlighted) ? $highlighted : $string;
238    }
239
240    /**
241     * Callback function for filtering HTML from URLs and images.
242     *
243     * @param array<int, string> $matches Array of matches from a regex pattern
244     */
245    public static function highlightNoLinks(array $matches): string
246    {
247        $prefix = $matches[3] ?? '';
248        $item = $matches[4] ?? '';
249        $postfix = $matches[5] ?? '';
250
251        if ($item !== '' && !self::isForbiddenElement($item)) {
252            return '<mark class="pmf-highlighted-string">' . $prefix . $item . $postfix . '</mark>';
253        }
254
255        // Fallback: the original matched string
256        return $matches[0];
257    }
258
259    /**
260     * Tries to detect if a string could be an HTML element
261     */
262    public static function isForbiddenElement(string $string): bool
263    {
264        $forbiddenElements = [
265            'img',
266            'picture',
267            'mark',
268        ];
269
270        foreach ($forbiddenElements as $forbiddenElement) {
271            if (!str_starts_with($forbiddenElement, $string)) {
272                continue;
273            }
274
275            return true;
276        }
277
278        return false;
279    }
280
281    /**
282     * Parses a given string and convert all the URLs into links.
283     */
284    public static function parseUrl(string $string): string
285    {
286        $protocols = ['http://', 'https://'];
287
288        $string = str_replace(search: $protocols, replace: '', subject: $string);
289        $string = str_replace(search: 'www.', replace: 'https://www.', subject: $string);
290
291        $pattern = '/(https?:\/\/[^\s]+)/i';
292
293        return preg_replace_callback(
294            $pattern,
295            static function (array $matches): string {
296                $url = htmlspecialchars($matches[1], ENT_QUOTES, encoding: 'UTF-8');
297                return '<a href="' . $url . '">' . $url . '</a>';
298            },
299            $string,
300        ) ?? $string;
301    }
302
303    /**
304     * Extracts the hostname from a given URL.
305     *
306     * @param string $url The URL from which to extract the hostname.
307     * @return string|null The hostname or null if the URL is invalid.
308     */
309    public static function getHostFromUrl(string $url): ?string
310    {
311        $parsedUrl = parse_url($url);
312
313        if (is_array($parsedUrl) && array_key_exists('host', $parsedUrl)) {
314            return $parsedUrl['host'];
315        }
316
317        return null;
318    }
319
320    /**
321     * Moves the given key of an array to the top
322     *
323     * @param array<string, array<string, string>> $array
324     */
325    public static function moveToTop(array &$array, string $key): void
326    {
327        if (array_key_exists($key, $array)) {
328            $temp = [$key => $array[$key]];
329            unset($array[$key]);
330            $array = $temp + $array;
331        }
332    }
333
334    /**
335     * Formats a given number of Bytes to kB, MB, GB, and so on.
336     */
337    public static function formatBytes(float|int $bytes, int $precision = 2): string
338    {
339        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
340
341        $bytes = max($bytes, 0);
342        $pow = floor(($bytes !== 0 ? log($bytes) : 0) / log(num: 1024));
343        $pow = min($pow, count($units) - 1);
344
345        $bytes /= 1 << (10 * $pow);
346
347        return round($bytes, $precision) . ' ' . $units[$pow];
348    }
349}