Passed
Push — master ( a24c1e...27cf1f )
by Andreas
32:08 queued 11s
created

midcom_services_i18n::_read_http_negotiation()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 7.0671

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 10
nop 1
dl 0
loc 28
ccs 16
cts 18
cp 0.8889
crap 7.0671
rs 8.8333
c 0
b 0
f 0
1
<?php
2
/**
3
 * @package midcom.services
4
 * @author The Midgard Project, http://www.midgard-project.org
5
 * @copyright The Midgard Project, http://www.midgard-project.org
6
 * @license http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
7
 */
8
9
use Symfony\Component\Intl\Intl;
10
use Symfony\Component\Intl\Languages;
11
use Symfony\Component\Intl\Locales;
12
use Symfony\Component\HttpFoundation\RequestStack;
13
use Symfony\Component\HttpFoundation\Request;
14
15
/**
16
 * This is a basic MidCOM Service which provides an interfaces to the
17
 * various I18n facilities of MidCOM.
18
 *
19
 * The I18n service serves as a central access point for all aspects
20
 * around internationalization and localization. It provides auto-detection
21
 * of language data using HTTP Content-Negotiation along with a cookie-based
22
 * fallback.
23
 *
24
 * This class is able to run independently from midcom_application
25
 * due to the fact that it is used in the cache_hit code.
26
 *
27
 * Use this class to set the language preferences (charset and locale) and to gain
28
 * access to the l10n string databases. A few helpers which can be used to ease
29
 * translation work (like charset conversion) are in here as well.
30
 *
31
 * All language codes used here are ISO 639-1 two-letter codes.
32
 *
33
 * @package midcom.services
34
 */
35
class midcom_services_i18n
36
{
37
    /**
38
     * Fallback language, in case the selected language is not available.
39
     *
40
     * @var string
41
     */
42
    private $_fallback_language;
43
44
    /**
45
     * Cache of all instantiated localization classes.
46
     *
47
     * @var midcom_services_i18n_l10n[]
48
     */
49
    private $_obj_l10n = [];
50
51
    /**
52
     * Current language.
53
     *
54
     * @var string
55
     */
56
    private $_current_language;
57
58
    /**
59
     * Current character set
60
     *
61
     * @var string
62
     */
63
    private $_current_charset = 'utf-8';
64
65
    /**
66
     * Initialize the available i18n framework by determining the desired language
67
     * from these different sources: HTTP Content Negotiation, Client side language cookie.
68
     *
69
     * Its two parameters set the default language in case that none is supplied
70
     * via HTTP Content Negotiation or through Cookies.
71
     *
72
     * The default language set on startup is currently hardcoded to 'en',
73
     * you should override it after initialization, if you want something
74
     * else using the setter methods below.
75
     *
76
     * The fallback language is read from the MidCOM configuration directive
77
     * <i>i18n_fallback_language</i>.
78
     */
79 3
    public function __construct(RequestStack $request_stack)
80
    {
81 3
        $this->_fallback_language = midcom::get()->config->get('i18n_fallback_language');
82
83 3
        $found = false;
84 3
        if ($request = $request_stack->getCurrentRequest()) {
85 2
            $found = $this->_read_cookie($request) || $this->_read_http_negotiation($request);
86
        }
87 3
        if (!$found) {
88 1
            $this->set_language($this->_fallback_language);
0 ignored issues
show
Bug introduced by
It seems like $this->_fallback_language can also be of type null; however, parameter $lang of midcom_services_i18n::set_language() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

88
            $this->set_language(/** @scrutinizer ignore-type */ $this->_fallback_language);
Loading history...
89
        }
90 3
    }
91
92
    /**
93
     * Try to pull the user's preferred language and
94
     * character set out of a cookie named "midcom_services_i18n".
95
     */
96 2
    private function _read_cookie(Request $request) : bool
97
    {
98 2
        if (!$request->cookies->has('midcom_services_i18n')) {
99 2
            return false;
100
        }
101
102
        $rawdata = base64_decode($request->cookies->get('midcom_services_i18n'));
103
        $array = unserialize($rawdata);
104
105
        if (   !array_key_exists('language', $array)
106
            || !array_key_exists('charset', $array)) {
107
            debug_add("Rejecting cookie, it seems invalid.");
108
            return false;
109
        }
110
        $this->set_charset($array['charset']);
111
        return $this->set_language($array['language']);
112
    }
113
114
    /**
115
     * Pull available language out of the HTTP Headers
116
     *
117
     * q-parameters for prioritization are supported.
118
     */
119 2
    private function _read_http_negotiation(Request $request) : bool
120
    {
121 2
        if (!$request->server->has('HTTP_ACCEPT_LANGUAGE')) {
122
            return false;
123
        }
124 2
        $rawdata = explode(",", $request->server->get('HTTP_ACCEPT_LANGUAGE'));
125 2
        $http_langs = [];
126 2
        foreach ($rawdata as $data) {
127 2
            $params = explode(";", $data);
128 2
            $lang = array_shift($params);
129
130
            // we can't use strings like en-US, so we only use the first two characters
131 2
            $lang = substr($lang, 0, 2);
132 2
            $q = $this->_get_q($params);
133
134 2
            if (   !isset($http_langs[$lang])
135 2
                || $http_langs[$lang] < $q) {
136 2
                $http_langs[$lang] = $q;
137
            }
138
        }
139 2
        arsort($http_langs, SORT_NUMERIC);
140 2
        foreach (array_keys($http_langs) as $name) {
141 2
            if ($this->set_language($name)) {
142 2
                return true;
143
            }
144
        }
145
146
        return false;
147
    }
148
149 2
    private function _get_q(array $params) : float
150
    {
151 2
        $q = 1.0;
152 2
        $option = array_shift($params);
153 2
        while ($option !== null) {
154 1
            $option_params = explode("=", $option);
155 1
            if (count($option_params) != 2) {
156
                $option = array_shift($params);
157
                continue;
158
            }
159 1
            if (   $option_params[0] == "q"
160 1
                && is_numeric($option_params[1])) {
161
                // make sure that 0.0 <= $q <= 1.0
162 1
                $q = max(0.0, min(1.0, $option_params[1]));
163
            }
164 1
            $option = array_shift($params);
165
        }
166 2
        return $q;
167
    }
168
169
    /**
170
     * Set output character set.
171
     *
172
     * @param string $charset    Charset name.
173
     */
174
    public function set_charset(string $charset)
175
    {
176
        $this->_current_charset = strtolower($charset);
177
    }
178
179
    /**
180
     * Set output language.
181
     *
182
     * This will set the character encoding to the language's default
183
     * encoding and will also set the system locale to the one
184
     * specified in the language database.
185
     *
186
     * If you want another character encoding as the default one, you
187
     * have to override it manually using midcom_services_i18n::set_charset()
188
     * after calling this method.
189
     *
190
     * @param string $lang    Language ISO 639-1 code
191
     */
192 3
    public function set_language(string $lang) : bool
193
    {
194 3
        if (Locales::getName($lang) === null) {
0 ignored issues
show
introduced by
The condition Symfony\Component\Intl\L...getName($lang) === null is always false.
Loading history...
195
            debug_add("Language {$lang} not found.", MIDCOM_LOG_ERROR);
196
            return false;
197
        }
198
199 3
        $this->_current_language = $lang;
200
201 3
        setlocale(LC_ALL, $lang);
202 3
        if (Intl::isExtensionLoaded()) {
203 3
            Locale::setDefault($lang);
204
        }
205
206 3
        foreach ($this->_obj_l10n as $object) {
207
            $object->set_language($lang);
208
        }
209 3
        return true;
210
    }
211
212
    /**
213
     * Set the fallback language.
214
     *
215
     * @param string $lang    Language name.
216
     */
217
    public function set_fallback_language(string $lang)
218
    {
219
        $this->_fallback_language = $lang;
220
        foreach ($this->_obj_l10n as $object) {
221
            $object->set_fallback_language($lang);
222
        }
223
    }
224
225
    /**
226
     * Returns the current language code
227
     *
228
     * @return string
229
     */
230 376
    public function get_current_language()
231
    {
232 376
        return $this->_current_language;
233
    }
234
235
    /**
236
     * Returns language code corresponding to current content language
237
     *
238
     * @return string
239
     */
240
    public function get_content_language()
241
    {
242
        return $this->get_current_language();
243
    }
244
245
    /**
246
     * Returns the current fallback language code
247
     *
248
     * @return string
249
     */
250 52
    public function get_fallback_language()
251
    {
252 52
        return $this->_fallback_language;
253
    }
254
255
    /**
256
     * Returns the current character set
257
     *
258
     * @return string
259
     */
260 103
    public function get_current_charset()
261
    {
262 103
        return $this->_current_charset;
263
    }
264
265
    /**
266
     * Returns a l10n class instance which can be used to
267
     * access the localization data of the current component.
268
     *
269
     * Using the special name "midcom" you will get the midcom core l10n library.
270
     *
271
     * @see midcom_services_i18n_l10n
272
     * @param string $component    The component for which to retrieve a string database.
273
     * @param string $database    The string table to retrieve from the component's locale directory.
274
     */
275 385
    public function get_l10n(string $component = 'midcom', string $database = 'default') : midcom_services_i18n_l10n
276
    {
277 385
        $cacheid = "{$component}/{$database}";
278
279 385
        if (!array_key_exists($cacheid, $this->_obj_l10n)) {
280 4
            $this->_load_l10n_db($component, $database);
281
        }
282
283 385
        return $this->_obj_l10n[$cacheid];
284
    }
285
286
    /**
287
     * Returns a translated string using the l10n database specified in the function
288
     * arguments.
289
     *
290
     * @param string $stringid The string to translate.
291
     * @param string $component    The component for which to retrieve a string database. If omitted, this defaults to the
292
     *     current component (out of the component context).
293
     * @param string $database    The string table to retrieve from the component's locale directory. If omitted, the 'default'
294
     *     database is used.
295
     * @see midcom_services_i18n_l10n::get()
296
     */
297 359
    public function get_string(string $stringid, $component = null, string $database = 'default') : string
298
    {
299 359
        if ($component === null) {
300 5
            $component = midcom_core_context::get()->get_key(MIDCOM_CONTEXT_COMPONENT) ?? 'midcom';
301
        }
302
303 359
        $cacheid = "{$component}/{$database}";
304 359
        if (!array_key_exists($cacheid, $this->_obj_l10n)) {
305 3
            $this->_load_l10n_db($component, $database);
306
        }
307
308 359
        return $this->_obj_l10n[$cacheid]->get($stringid);
309
    }
310
311
    /**
312
     * This is a shortcut for echo $this->get_string(...);.
313
     *
314
     * To keep the naming stable with the actual l10n class, this is not called
315
     * echo_string (Zend won't allow $l10n->echo().)
316
     *
317
     * @param string $stringid The string to translate.
318
     * @param string $component    The component for which to retrieve a string database. If omitted, this defaults to the
319
     *     current component (out of the component context).
320
     * @param string $database    The string table to retrieve from the component's locale directory. If omitted, the 'default'
321
     *     database is used.
322
     * @see midcom_services_i18n_l10n::get()
323
     * @see get_string()
324
     */
325
    public function show_string(string $stringid, $component = null, string $database = 'default')
326
    {
327
        echo $this->get_string($stringid, $component, $database);
328
    }
329
330
    /**
331
     * Load the specified l10n library.
332
     *
333
     * If loading the library failed, midcom_error is thrown, otherwise the l10n
334
     * db cache is populated accordingly.
335
     *
336
     * @param string $component    The component for which to retrieve a string database.
337
     * @param string $database    The string table to retrieve from the component's locale directory.
338
     */
339 6
    private function _load_l10n_db(string $component, string $database)
340
    {
341 6
        $cacheid = "{$component}/{$database}";
342 6
        $obj = new midcom_services_i18n_l10n($component, $database);
343
344 6
        $obj->set_language($this->_current_language);
345 6
        $obj->set_fallback_language($this->_fallback_language);
346 6
        $this->_obj_l10n[$cacheid] = $obj;
347 6
    }
348
349
    /**
350
     * Lists languages as identifier -> name pairs
351
     */
352 2
    public function list_languages() : array
353
    {
354 2
        $languages = Languages::getNames('en');
355 2
        foreach ($languages as $identifier => &$language) {
356 2
            $localname = Languages::getName($identifier, $identifier);
357 2
            if ($localname != $language) {
358 2
                $language .= ' (' . $localname . ')';
359
            }
360
        }
361 2
        return $languages;
362
    }
363
364
    /**
365
     * This is a calling wrapper to the iconv library.
366
     *
367
     * See the PHP iconv() function for the exact parameter definitions.
368
     *
369
     * @param string $source_charset The charset to convert from.
370
     * @param string $destination_charset The charset to convert to.
371
     * @param string $string The string to convert.
372
     * @return mixed The converted string or false on any error.
373
     */
374
    private function iconv(string $source_charset, string $destination_charset, string $string)
375
    {
376
        $result = @iconv($source_charset, $destination_charset, $string);
377
        if ($result === false && !empty($string)) {
378
            debug_add("Iconv returned failed to convert a string, returning an empty string.", MIDCOM_LOG_WARN);
379
            debug_print_r("Tried to convert this string from {$source_charset} to {$destination_charset}:", $string);
380
            midcom::get()->debug->log_php_error(MIDCOM_LOG_WARN);
381
            return false;
382
        }
383
        return $result;
384
    }
385
386
    /**
387
     * Convert a string assumed to be in the currently active charset to UTF8.
388
     *
389
     * @param string $string The string to convert
390
     * @return string The string converted to UTF-8
391
     */
392
    public function convert_to_utf8(string $string)
393
    {
394
        if ($this->_current_charset == 'utf-8') {
395
            return $string;
396
        }
397
        return $this->iconv($this->_current_charset, 'utf-8', $string);
398
    }
399
400
    /**
401
     * Convert a string assumed to be in UTF-8 to the currently active charset.
402
     *
403
     * @param string $string The string to convert
404
     * @return string The string converted to the current charset
405
     */
406 371
    public function convert_from_utf8(string $string)
407
    {
408 371
        if ($this->_current_charset == 'utf-8') {
409 371
            return $string;
410
        }
411
        return $this->iconv('utf-8', $this->_current_charset, $string);
412
    }
413
414
    /**
415
     * Converts the given string to the current site charset.
416
     *
417
     * @param string $string The string to convert.
418
     * @return string The converted string.
419
     */
420
    public function convert_to_current_charset(string $string)
421
    {
422
        $charset = mb_detect_encoding($string, "UTF-8, UTF-7, ASCII, ISO-8859-15");
423
        debug_add("mb_detect_encoding got {$charset}");
424
        return $this->iconv($charset, $this->_current_charset, $string);
425
    }
426
427
    /**
428
     * Wrapped html_entity_decode call
429
     *
430
     * @param string $text The text with HTML entities, which should be replaced by their native equivalents.
431
     */
432 14
    public function html_entity_decode(string $text) : string
433
    {
434 14
        return html_entity_decode($text, ENT_COMPAT, $this->_current_charset);
435
    }
436
}
437