Passed
Pull Request — master (#40)
by William
10:09
created

Translator::getTranslations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
ccs 0
cts 0
cp 0
cc 1
nc 1
nop 0
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
    Copyright (c) 2003, 2009 Danilo Segan <[email protected]>.
7
    Copyright (c) 2005 Nico Kaiser <[email protected]>
8
    Copyright (c) 2016 Michal Čihař <[email protected]>
9
10
    This file is part of MoTranslator.
11
12
    This program is free software; you can redistribute it and/or modify
13
    it under the terms of the GNU General Public License as published by
14
    the Free Software Foundation; either version 2 of the License, or
15
    (at your option) any later version.
16
17
    This program is distributed in the hope that it will be useful,
18
    but WITHOUT ANY WARRANTY; without even the implied warranty of
19
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20
    GNU General Public License for more details.
21
22
    You should have received a copy of the GNU General Public License along
23
    with this program; if not, write to the Free Software Foundation, Inc.,
24
    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
25
*/
26
27
namespace PhpMyAdmin\MoTranslator;
28
29
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
30
use Throwable;
31
use function array_key_exists;
32
use function chr;
33
use function count;
34
use function explode;
35
use function implode;
36
use function intval;
37
use function is_readable;
38
use function ltrim;
39
use function preg_replace;
40
use function rtrim;
41
use function strcmp;
42
use function stripos;
43
use function strpos;
44
use function strtolower;
45
use function substr;
46
use function trim;
47
48
/**
49
 * Provides a simple gettext replacement that works independently from
50
 * the system's gettext abilities.
51
 * It can read MO files and use them for translating strings.
52
 *
53
 * It caches ll strings and translations to speed up the string lookup.
54
 */
55
class Translator
56
{
57
    /**
58
     * None error.
59
     */
60
    public const ERROR_NONE = 0;
61
    /**
62
     * File does not exist.
63
     */
64
    public const ERROR_DOES_NOT_EXIST = 1;
65
    /**
66
     * File has bad magic number.
67
     */
68
    public const ERROR_BAD_MAGIC = 2;
69
    /**
70
     * Error while reading file, probably too short.
71
     */
72
    public const ERROR_READING = 3;
73
74
    /**
75
     * Big endian mo file magic bytes.
76
     */
77
    public const MAGIC_BE = "\x95\x04\x12\xde";
78
    /**
79
     * Little endian mo file magic bytes.
80
     */
81
    public const MAGIC_LE = "\xde\x12\x04\x95";
82
83
    /**
84
     * Parse error code (0 if no error).
85
     *
86
     * @var int
87
     */
88
    public $error = self::ERROR_NONE;
89
90
    /**
91
     * Cache header field for plural forms.
92
     *
93
     * @var string|null
94
     */
95
    private $pluralEquation = null;
96
97
    /** @var ExpressionLanguage|null Evaluator for plurals */
98
    private $pluralExpression = null;
99
100
    /** @var int|null number of plurals */
101
    private $pluralCount = null;
102
103
    /**
104
     * Array with original -> translation mapping.
105
     *
106
     * @var array<string,string>
107
     */
108
    private $cacheTranslations = [];
109
110
    /**
111
     * @param string|null $filename Name of mo file to load (null to not load a file)
112 180
     */
113
    public function __construct(?string $filename)
114 180
    {
115 24
        // The user can load the translations manually
116
        if ($filename === null) {
117 24
            return;
118
        }
119
120 156
        $this->loadTranslationsFromFile($filename);
121
    }
122
123 156
    /**
124 152
     * Load a Mo file translations
125 120
     *
126 32
     * @param string $filename Name of mo file to load
127 28
     */
128
    private function loadTranslationsFromFile(string $filename): void
129 4
    {
130
        if (! is_readable($filename)) {
131 4
            $this->error = self::ERROR_DOES_NOT_EXIST;
132
133
            return;
134
        }
135 148
136 148
        $stream = new StringReader($filename);
137 136
138
        try {
139
            $magic = $stream->read(0, 4);
140 136
            if (strcmp($magic, self::MAGIC_LE) === 0) {
141 132
                $unpack = 'V';
142
            } elseif (strcmp($magic, self::MAGIC_BE) === 0) {
143
                $unpack = 'N';
144 132
            } else {
145 132
                $this->error = self::ERROR_BAD_MAGIC;
146 132
147 132
                return;
148
            }
149 20
150 20
            /* Parse header */
151
            $total = $stream->readint($unpack, 8);
152 20
            $originals = $stream->readint($unpack, 12);
153
            $translations = $stream->readint($unpack, 16);
154 132
155
            /* get original and translations tables */
156
            $tableOriginals = $stream->readintarray($unpack, $originals, $total * 2);
157
            $tableTranslations = $stream->readintarray($unpack, $translations, $total * 2);
158
159
            /* read all strings to the cache */
160
            for ($i = 0; $i < $total; ++$i) {
161
                $original = $stream->read($tableOriginals[$i * 2 + 2], $tableOriginals[$i * 2 + 1]);
162
                $translation = $stream->read($tableTranslations[$i * 2 + 2], $tableTranslations[$i * 2 + 1]);
163 120
                $this->cacheTranslations[$original] = $translation;
164
            }
165 120
        } catch (ReaderException $e) {
166 84
            $this->error = self::ERROR_READING;
167
168
            return;
169 60
        }
170
    }
171
172
    /**
173
     * Translates a string.
174
     *
175
     * @param string $msgid String to be translated
176
     *
177 24
     * @return string translated string (or original, if not found)
178
     */
179 24
    public function gettext(string $msgid): string
180
    {
181
        if (array_key_exists($msgid, $this->cacheTranslations)) {
182
            return $this->cacheTranslations[$msgid];
183
        }
184
185
        return $msgid;
186
    }
187
188
    /**
189 56
     * Check if a string is translated.
190
     *
191
     * @param string $msgid String to be checked
192 56
     */
193 56
    public function exists(string $msgid): bool
194 44
    {
195
        return array_key_exists($msgid, $this->cacheTranslations);
196 12
    }
197
198
    /**
199 56
     * Sanitize plural form expression for use in ExpressionLanguage.
200
     *
201 56
     * @param string $expr Expression to sanitize
202 48
     *
203
     * @return string sanitized plural form expression
204
     */
205
    public static function sanitizePluralExpression(string $expr): string
206 56
    {
207 48
        // Parse equation
208
        $expr = explode(';', $expr);
209
        if (count($expr) >= 2) {
210
            $expr = $expr[1];
211 56
        } else {
212
            $expr = $expr[0];
213 56
        }
214
215
        $expr = trim(strtolower($expr));
216
        // Strip plural prefix
217
        if (substr($expr, 0, 6) === 'plural') {
218
            $expr = ltrim(substr($expr, 6));
219
        }
220
221
        // Strip equals
222
        if (substr($expr, 0, 1) === '=') {
223 52
            $expr = ltrim(substr($expr, 1));
224
        }
225 52
226 52
        // Cleanup from unwanted chars
227 52
        $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr);
228 8
229
        return (string) $expr;
230
    }
231 44
232 4
    /**
233
     * Extracts number of plurals from plurals form expression.
234
     *
235 40
     * @param string $expr Expression to process
236
     *
237
     * @return int Total number of plurals
238
     */
239
    public static function extractPluralCount(string $expr): int
240
    {
241
        $parts = explode(';', $expr, 2);
242
        $nplurals = explode('=', trim($parts[0]), 2);
243
        if (strtolower(rtrim($nplurals[0])) !== 'nplurals') {
244
            return 1;
245 44
        }
246
247 44
        if (count($nplurals) === 1) {
248 44
            return 1;
249 44
        }
250 44
251 44
        return intval($nplurals[1]);
252
    }
253
254 28
    /**
255
     * Parse full PO header and extract only plural forms line.
256
     *
257 44
     * @param string $header Gettext header
258
     *
259
     * @return string verbatim plural form header field
260
     */
261
    public static function extractPluralsForms(string $header): string
262
    {
263
        $headers = explode("\n", $header);
264
        $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;';
265 32
        foreach ($headers as $header) {
266
            if (stripos($header, 'Plural-Forms:') !== 0) {
267
                continue;
268
            }
269
270
            $expr = substr($header, 13);
271 32
        }
272 28
273 20
        return $expr;
274
    }
275 8
276
    /**
277
     * Get possible plural forms from MO header.
278 28
     *
279 28
     * @return string plural form header
280 28
     */
281
    private function getPluralForms(): string
282
    {
283 32
        // lets assume message number 0 is header
284
        // this is true, right?
285
286
        // cache header field for plural forms
287
        if ($this->pluralEquation === null) {
288
            if (isset($this->cacheTranslations[''])) {
289
                $header = $this->cacheTranslations[''];
290
            } else {
291
                $header = '';
292
            }
293 32
294
            $expr = $this->extractPluralsForms($header);
295 32
            $this->pluralEquation = $this->sanitizePluralExpression($expr);
296 28
            $this->pluralCount = $this->extractPluralCount($expr);
297
        }
298
299
        return $this->pluralEquation;
300 32
    }
301 32
302 32
    /**
303
     * Detects which plural form to take.
304 4
     *
305 4
     * @param int $n count of objects
306
     *
307
     * @return int array index of the right plural form
308 32
     */
309 4
    private function selectString(int $n): int
310
    {
311
        if ($this->pluralExpression === null) {
312 32
            $this->pluralExpression = new ExpressionLanguage();
313
        }
314
315
        try {
316
            $plural = $this->pluralExpression->evaluate(
317
                $this->getPluralForms(),
318
                ['n' => $n]
319
            );
320
            if ($plural === false) {
0 ignored issues
show
introduced by
The condition $plural === false is always false.
Loading history...
321
                $plural = 0;
322
            }
323
        } catch (Throwable $e) {
324 72
            $plural = 0;
325
        }
326
327 72
        if ($plural >= $this->pluralCount) {
328 72
            $plural = $this->pluralCount - 1;
329 72
        }
330
331
        return $plural;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $plural could return the type array which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
332
    }
333 32
334
    /**
335 32
     * Plural version of gettext.
336 32
     *
337 32
     * @param string $msgid       Single form
338
     * @param string $msgidPlural Plural form
339
     * @param int    $number      Number of objects
340
     *
341 32
     * @return string translated plural form
342 4
     */
343
    public function ngettext(string $msgid, string $msgidPlural, int $number): string
344
    {
345 32
        // this should contains all strings separated by NULLs
346
        $key = implode(chr(0), [$msgid, $msgidPlural]);
347
        if (! array_key_exists($key, $this->cacheTranslations)) {
348
            return $number !== 1 ? $msgidPlural : $msgid;
349
        }
350
351
        // find out the appropriate form
352
        $select = $this->selectString($number);
353
354
        $result = $this->cacheTranslations[$key];
355
        $list = explode(chr(0), $result);
356 56
        // @codeCoverageIgnoreStart
357
        if ($list === false) {
358 56
            // This was added in 3ff2c63bcf85f81b3a205ce7222de11b33e2bf56 for phpstan
359 56
            // But according to the php manual it should never happen
360 56
            return '';
361 24
        }
362
        // @codeCoverageIgnoreEnd
363
364 32
        if (! isset($list[$select])) {
365
            return $list[0];
366
        }
367
368
        return $list[$select];
369
    }
370
371
    /**
372
     * Translate with context.
373
     *
374
     * @param string $msgctxt Context
375
     * @param string $msgid   String to be translated
376
     *
377 16
     * @return string translated plural form
378
     */
379 16
    public function pgettext(string $msgctxt, string $msgid): string
380 16
    {
381 16
        $key = implode(chr(4), [$msgctxt, $msgid]);
382 4
        $ret = $this->gettext($key);
383
        if (strpos($ret, chr(4)) !== false) {
384
            return $msgid;
385 12
        }
386
387
        return $ret;
388
    }
389
390
    /**
391
     * Plural version of pgettext.
392
     *
393
     * @param string $msgctxt     Context
394 4
     * @param string $msgid       Single form
395
     * @param string $msgidPlural Plural form
396 4
     * @param int    $number      Number of objects
397 4
     *
398
     * @return string translated plural form
399
     */
400
    public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string
401
    {
402
        $key = implode(chr(4), [$msgctxt, $msgid]);
403
        $ret = $this->ngettext($key, $msgidPlural, $number);
404
        if (strpos($ret, chr(4)) !== false) {
405
            return $msgid;
406
        }
407
408
        return $ret;
409
    }
410
411
    /**
412
     * Set translation in place
413
     *
414
     * @param string $msgid  String to be set
415
     * @param string $msgstr Translation
416
     */
417
    public function setTranslation(string $msgid, string $msgstr): void
418
    {
419
        $this->cacheTranslations[$msgid] = $msgstr;
420
    }
421
422
    /**
423
     * Set the translations
424
     *
425
     * @param array<string,string> $translations The translations "key => value" array
426
     */
427
    public function setTranslations(array $translations): void
428
    {
429
        $this->cacheTranslations = $translations;
430
    }
431
432
    /**
433
     * Get the translations
434
     *
435
     * @return array<string,string> The translations "key => value" array
436
     */
437
    public function getTranslations(): array
438
    {
439
        return $this->cacheTranslations;
440
    }
441
}
442