Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
45 / 50
83.33% covered (success)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageDetector
90.00% covered (success)
90.00%
45 / 50
83.33% covered (success)
83.33%
10 / 12
26.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 detectAllWithBrowser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 detectAllFromConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAcceptLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 selectLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 aggregateWithBrowser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 aggregateWithoutBrowser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fetchFilteredLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getSessionLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getConfigLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getDetectionLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 initUserAgentLanguage
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
7.33
1<?php
2
3/**
4 * The language detector class.
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 * @copyright 2025-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2025-11-07
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Language;
21
22use phpMyFAQ\Filter;
23use phpMyFAQ\Language;
24use Symfony\Component\HttpFoundation\Request;
25use Symfony\Component\HttpFoundation\Session\SessionInterface;
26
27class LanguageDetector
28{
29    private string $acceptLanguage = '';
30
31    public function __construct(
32        private readonly SessionInterface $session,
33    ) {
34    }
35
36    /** @return array<string, string|null> */
37    public function detectAllWithBrowser(string $configLanguage): array
38    {
39        $this->initUserAgentLanguage();
40        return $this->aggregateWithBrowser($configLanguage);
41    }
42
43    /** @return array<string, string|null> */
44    public function detectAllFromConfig(string $configLanguage): array
45    {
46        return $this->aggregateWithoutBrowser($configLanguage);
47    }
48
49    public function getAcceptLanguage(): string
50    {
51        return $this->acceptLanguage;
52    }
53
54    /**
55     * Selects the first valid language from the detected list in a defined priority
56     * and falls back to 'en' if none was found.
57     *
58     * Priority: POST > GET(lang) > GET(artlang) > Session > Browser detection > Config
59     *
60     * @param array<string, string|null> $detected
61     */
62    public function selectLanguage(array $detected): string
63    {
64        $order = ['post', 'get', 'artget', 'session', 'detection', 'config'];
65        foreach ($order as $key) {
66            $lang = $detected[$key] ?? null;
67            if ($lang !== null && Language::isASupportedLanguage($lang)) {
68                return strtolower($lang);
69            }
70        }
71
72        return 'en';
73    }
74
75    /** @return array<string, string|null> */
76    private function aggregateWithBrowser(string $configLanguage): array
77    {
78        return [
79            'post' => $this->fetchFilteredLanguage(INPUT_POST, variable: 'language'),
80            'get' => $this->fetchFilteredLanguage(INPUT_GET, variable: 'lang'),
81            'artget' => $this->fetchFilteredLanguage(INPUT_GET, variable: 'artlang'),
82            'session' => $this->getSessionLanguage(),
83            'config' => $this->getConfigLanguage($configLanguage),
84            'detection' => $this->getDetectionLanguage(),
85        ];
86    }
87
88    /** @return array<string, string|null> */
89    private function aggregateWithoutBrowser(string $configLanguage): array
90    {
91        return [
92            'post' => $this->fetchFilteredLanguage(INPUT_POST, variable: 'language'),
93            'get' => $this->fetchFilteredLanguage(INPUT_GET, variable: 'lang'),
94            'artget' => $this->fetchFilteredLanguage(INPUT_GET, variable: 'artlang'),
95            'session' => $this->getSessionLanguage(),
96            'config' => $this->getConfigLanguage($configLanguage),
97            'detection' => null,
98        ];
99    }
100
101    private function fetchFilteredLanguage(int $inputType, string $variable): ?string
102    {
103        $filteredLang = Filter::filterInput($inputType, variableName: $variable, filter: FILTER_SANITIZE_SPECIAL_CHARS);
104        $lang = is_string($filteredLang) ? $filteredLang : null;
105        return Language::isASupportedLanguage($lang) ? $lang : null;
106    }
107
108    private function getSessionLanguage(): ?string
109    {
110        $sessionLang = $this->session->get(name: 'lang');
111        $lang = is_string($sessionLang) ? $sessionLang : null;
112        return Language::isASupportedLanguage($lang) ? trim((string) $lang) : null;
113    }
114
115    private function getConfigLanguage(string $configLanguage): ?string
116    {
117        $lang = str_replace(['language_', '.php'], replace: '', subject: $configLanguage);
118        return Language::isASupportedLanguage($lang) ? $lang : null;
119    }
120
121    private function getDetectionLanguage(): ?string
122    {
123        return Language::isASupportedLanguage(strtoupper($this->acceptLanguage))
124            ? strtolower($this->acceptLanguage)
125            : null;
126    }
127
128    private function initUserAgentLanguage(): void
129    {
130        $languages = Request::createFromGlobals()->getLanguages();
131        foreach ($languages as $language) {
132            if (!Language::isASupportedLanguage(strtoupper($language))) {
133                continue;
134            }
135
136            $this->acceptLanguage = strtolower($language);
137            break;
138        }
139
140        if ($this->acceptLanguage === '') {
141            foreach ($languages as $language) {
142                $short = substr($language, offset: 0, length: 2);
143                if (Language::isASupportedLanguage(strtoupper($short))) {
144                    $this->acceptLanguage = strtolower($short);
145                    break;
146                }
147            }
148        }
149    }
150}