Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.71% covered (success)
98.71%
153 / 155
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Translation
98.71% covered (success)
98.71%
153 / 155
92.00% covered (success)
92.00%
23 / 25
63
0.00% covered (danger)
0.00%
0 / 1
 create
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resetInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 getString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 has
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 getAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setTranslationsDir
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultLanguage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentLanguage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInstance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setMultiByteLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getConfigurationItems
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 registerPluginTranslations
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 checkDefaultLanguageLoaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkLanguageLoaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureLanguageLoaded
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 checkTranslationsDirectory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkDefaultLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkInit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 performInit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 checkCurrentLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 filename
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchTranslationFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Translation class provides methods and functions for the
5 * translation file handling.
6 *
7 * This Source Code Form is subject to the terms of the Mozilla Public License,
8 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
9 * obtain one at https://mozilla.org/MPL/2.0/.
10 *
11 * @package   phpMyFAQ
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @copyright 2022-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     2022-03-20
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ;
22
23use phpMyFAQ\Core\Exception;
24
25class Translation
26{
27    /**  @var string The directory with the language files */
28    protected string $translationsDir = 'translations';
29
30    /** @var string The default fallback language */
31    protected string $defaultLanguage = 'en';
32
33    /** @var string The current language */
34    protected string $currentLanguage = '';
35
36    /** @var array<string, array<string, string|array<int, string>>> The loaded languages */
37    protected array $loadedLanguages = [];
38
39    /** @var array<string, array<string, array<string, string>>> Plugin translations: [pluginName][language][key] */
40    protected array $pluginTranslations = [];
41
42    /** @var bool Translation already initialized? */
43    protected bool $isReady = false;
44
45    private static ?Translation $translation = null;
46
47    public static function create(): Translation
48    {
49        if (!self::$translation instanceof Translation) {
50            self::$translation = new self();
51        }
52
53        return self::$translation;
54    }
55
56    /**
57     * @internal Only for tests to reset the static instance.
58     */
59    public static function resetInstance(): void
60    {
61        self::$translation = null;
62    }
63
64    /**
65     * Returns the translation of a specific key from the current language.
66     * Returns a string for regular keys, an array for plural form keys, or null if not found.
67     *
68     * @return string|string[]|null
69     */
70    public static function get(string $key): string|array|null
71    {
72        try {
73            $translation = self::getInstance();
74            $translation->checkInit();
75            $translation->checkLanguageLoaded();
76
77            // Check if the key uses the plugin namespace format: plugin.PluginName.messageKey
78            if (str_starts_with($key, 'plugin.')) {
79                $parts = explode(separator: '.', string: $key, limit: 3);
80
81                if (count($parts) === 3) {
82                    [$namespace, $pluginName, $messageKey] = $parts;
83
84                    // Try the current language first
85                    $currentTranslation =
86                        $translation->pluginTranslations[$pluginName][$translation->currentLanguage][$messageKey]
87                        ?? null;
88                    if ($currentTranslation !== null) {
89                        return $currentTranslation;
90                    }
91
92                    return (
93                        $translation->pluginTranslations[$pluginName][$translation->defaultLanguage][$messageKey]
94                        ?? null
95                    );
96                }
97            }
98
99            $currentLanguageTranslations = $translation->loadedLanguages[$translation->currentLanguage];
100            if (array_key_exists($key, $currentLanguageTranslations) && $currentLanguageTranslations[$key] !== '') {
101                return $currentLanguageTranslations[$key];
102            }
103
104            return $translation->loadedLanguages[$translation->defaultLanguage][$key] ?? null;
105        } catch (Exception) {
106            Configuration::getConfigurationInstance()
107                ->getLogger()
108                ->error('Error while fetching translation key: ' . $key);
109        }
110
111        return null;
112    }
113
114    /**
115     * Returns the translation of a specific key as a string.
116     * Use this for keys that are known to always return a string (not plural form keys).
117     * Returns an empty string if the key is not found or returns an array.
118     */
119    public static function getString(string $key): string
120    {
121        $value = self::get($key);
122
123        return is_string($value) ? $value : '';
124    }
125
126    /**
127     * Checks if a specific translation key exists in the current or default language.
128     */
129    public static function has(string $key): bool
130    {
131        try {
132            $translation = self::getInstance();
133            $translation->checkInit();
134            $translation->checkLanguageLoaded();
135
136            // Check plugin namespace
137            if (str_starts_with($key, 'plugin.')) {
138                $parts = explode(separator: '.', string: $key, limit: 3);
139
140                if (count($parts) === 3) {
141                    [$namespace, $pluginName, $messageKey] = $parts;
142                    $currentLanguagePluginTranslations = $translation->pluginTranslations[$pluginName][$translation->currentLanguage]
143                    ?? [];
144                    if (array_key_exists($messageKey, $currentLanguagePluginTranslations)) {
145                        return true;
146                    }
147
148                    $defaultLanguagePluginTranslations = $translation->pluginTranslations[$pluginName][$translation->defaultLanguage]
149                    ?? [];
150                    return array_key_exists($messageKey, $defaultLanguagePluginTranslations);
151                }
152            }
153
154            // Original core logic
155            $currentLanguageTranslations = $translation->loadedLanguages[$translation->currentLanguage];
156            if (array_key_exists($key, $currentLanguageTranslations)) {
157                return true;
158            }
159
160            $defaultLanguageTranslations = $translation->loadedLanguages[$translation->defaultLanguage];
161            if (array_key_exists($key, $defaultLanguageTranslations)) {
162                return true;
163            }
164        } catch (Exception) {
165            return false;
166        }
167
168        return false;
169    }
170
171    /**
172     * Returns all translations from the current language.
173     * @throws Exception
174     * @return array<string, string|array<int, string>>
175     */
176    public static function getAll(): array
177    {
178        $translation = self::getInstance();
179        $translation->checkInit();
180        $translation->checkLanguageLoaded();
181
182        return $translation->loadedLanguages[$translation->currentLanguage];
183    }
184
185    /**
186     * @throws Exception
187     */
188    public function setTranslationsDir(string $translationsDir): Translation
189    {
190        $this->translationsDir = $translationsDir;
191        $this->checkTranslationsDirectory();
192
193        return $this;
194    }
195
196    /**
197     * @throws Exception
198     */
199    public function setDefaultLanguage(string $defaultLanguage): Translation
200    {
201        $this->defaultLanguage = $defaultLanguage;
202        $this->checkDefaultLanguage();
203        $this->checkDefaultLanguageLoaded();
204
205        return $this;
206    }
207
208    public function getDefaultLanguage(): string
209    {
210        return $this->defaultLanguage;
211    }
212
213    /**
214     * @throws Exception
215     */
216    public function setCurrentLanguage(string $currentLanguage): Translation
217    {
218        $this->checkInit();
219        $this->currentLanguage = $currentLanguage;
220        $this->checkLanguageLoaded();
221
222        return $this;
223    }
224
225    public function getCurrentLanguage(): string
226    {
227        return $this->currentLanguage;
228    }
229
230    /**
231     * Returns the single instance.
232     */
233    public static function getInstance(): Translation
234    {
235        if (!self::$translation instanceof Translation) {
236            $className = self::class;
237            self::$translation = new $className();
238        }
239
240        return self::$translation;
241    }
242
243    /**
244     * Use "mbstring" extension if available and when possible
245     */
246    public function setMultiByteLanguage(): void
247    {
248        $validMultiByteStrings = ['ja', 'en', 'uni'];
249        $multiByteLanguage = self::get(key: 'metaLanguage') !== 'ja' ? 'uni' : self::get(key: 'metaLanguage');
250        if (
251            function_exists(function: 'mb_language')
252            && in_array($multiByteLanguage, $validMultiByteStrings, strict: true)
253        ) {
254            mb_language($multiByteLanguage);
255            mb_internal_encoding(encoding: 'utf-8');
256        }
257    }
258
259    /**
260     * Returns the configuration items from the current language for the given section.
261     *
262     * @return array<string, array<string, string>>
263     */
264    public static function getConfigurationItems(string $section = ''): array
265    {
266        $configuration = [];
267
268        foreach (self::fetchTranslationFile() as $key => $value) {
269            if (!str_starts_with($key, $section)) {
270                continue;
271            }
272
273            $configuration[$key] = [
274                'element' => $value[0] ?? '',
275                'label' => $value[1] ?? '',
276                'description' => $value[2] ?? '',
277            ];
278
279            switch ($key) {
280                case 'records.maxAttachmentSize':
281                    $configuration[$key]['label'] = sprintf(
282                        $configuration[$key]['label'],
283                        ini_get(option: 'upload_max_filesize'),
284                    );
285                    break;
286                case 'main.dateFormat':
287                    $configuration[$key]['label'] =
288                        '<a target="_blank" href="https://www.php.net/manual/en/function.date.php">'
289                        . $configuration[$key]['label']
290                        . '</a>';
291                    break;
292            }
293        }
294
295        Utils::moveToTop($configuration, key: 'main.maintenanceMode');
296
297        return $configuration;
298    }
299
300    /**
301     * Registers translations for a plugin from its translations directory
302     *
303     * @param string $pluginName The plugin name (for namespace)
304     * @param string $translationsDir Absolute path to plugin's translations directory
305     * @throws Exception
306     */
307    public function registerPluginTranslations(string $pluginName, string $translationsDir): void
308    {
309        if (!is_dir($translationsDir)) {
310            return; // Silently skip if no translations directory
311        }
312
313        // Load all language files from the plugin translations directory
314        $languageFiles = glob($translationsDir . '/language_*.php');
315
316        if ($languageFiles === false) {
317            return; // Silently skip if the glob fails
318        }
319
320        foreach ($languageFiles as $languageFile) {
321            // Extract language code from the filename: language_en.php -> en
322            $matches = [];
323            if (!preg_match('/language_([a-z]{2,3}(_[a-z]{2})?)\.php$/i', basename($languageFile), $matches)) {
324                continue;
325            }
326
327            $langCode = strtolower($matches[1]);
328
329            // Include the file and extract the $PMF_LANG array
330            $PMF_LANG = [];
331            include $languageFile;
332
333            // Store in a namespaced structure
334            if (!array_key_exists($pluginName, $this->pluginTranslations)) {
335                $this->pluginTranslations[$pluginName] = [];
336            }
337
338            $this->pluginTranslations[$pluginName][$langCode] = $PMF_LANG;
339        }
340    }
341
342    /**
343     * Checks if the default language is already loaded.
344     */
345    protected function checkDefaultLanguageLoaded(): void
346    {
347        $this->ensureLanguageLoaded($this->defaultLanguage);
348    }
349
350    /**
351     * Checks if the current language is already loaded. Loading new language only when needed.
352     */
353    protected function checkLanguageLoaded(): void
354    {
355        $this->ensureLanguageLoaded($this->currentLanguage);
356    }
357
358    /**
359     * Ensures that the given language is loaded in the cache.
360     */
361    private function ensureLanguageLoaded(string $language): void
362    {
363        if (array_key_exists($language, $this->loadedLanguages) && $this->loadedLanguages[$language] !== []) {
364            return;
365        }
366
367        $this->checkCurrentLanguage();
368        $loaded = require $this->filename($language);
369        $translations = [];
370        if (is_array($loaded)) {
371            foreach ($loaded as $translationKey => $translationValue) {
372                if (is_string($translationValue)) {
373                    $translations[(string) $translationKey] = $translationValue;
374                    continue;
375                }
376
377                if (!is_array($translationValue)) {
378                    continue;
379                }
380
381                $pluralForms = [];
382                foreach ($translationValue as $pluralIndex => $pluralForm) {
383                    $pluralForms[(int) $pluralIndex] = (string) $pluralForm;
384                }
385
386                $translations[(string) $translationKey] = $pluralForms;
387            }
388        }
389
390        $this->loadedLanguages[$language] = $translations;
391    }
392
393    /**
394     * Checks if the translations directory exists. If not, throw an exception.
395     * @throws Exception
396     */
397    protected function checkTranslationsDirectory(): void
398    {
399        if (!is_dir($this->translationsDir)) {
400            throw new Exception('The directory ' . $this->translationsDir . ' was not found!');
401        }
402    }
403
404    /**
405     * Checks if the default language exists. If not, throw an exception.
406     * @throws Exception
407     */
408    protected function checkDefaultLanguage(): void
409    {
410        if (!file_exists($this->filename($this->defaultLanguage))) {
411            throw new Exception('Default language "' . $this->defaultLanguage . '"not found!');
412        }
413    }
414
415    /**
416     * Checks if the Translation class has been initialized.
417     * @throws Exception
418     */
419    protected function checkInit(): void
420    {
421        if (!$this->isReady) {
422            $this->performInit();
423        }
424    }
425
426    /**
427     * Performs the actual initialization logic.
428     *
429     * @throws Exception
430     */
431    private function performInit(): void
432    {
433        $this->checkTranslationsDirectory();
434        $this->checkDefaultLanguage();
435
436        $this->isReady = true;
437    }
438
439    /**
440     * Checks if locale for the current language exists. If not, start using the default language.
441     */
442    protected function checkCurrentLanguage(): void
443    {
444        if (!file_exists($this->filename($this->currentLanguage))) {
445            $this->currentLanguage = $this->defaultLanguage;
446        }
447    }
448
449    /**
450     * Returns the filename for the given language.
451     */
452    protected function filename(string $language): string
453    {
454        return $this->translationsDir . DIRECTORY_SEPARATOR . 'language_' . strtolower($language) . '.php';
455    }
456
457    /**
458     * Fetches the translation file for the current language.
459     * @return array<string, array<string, string>>
460     */
461    private static function fetchTranslationFile(): array
462    {
463        $translation = self::getInstance();
464
465        $LANG_CONF = [];
466        include $translation->filename(language: 'en');
467        include $translation->filename($translation->currentLanguage);
468
469        return $LANG_CONF;
470    }
471}