Completed
Push — master ( 6a08aa...2252e8 )
by William
03:47
created

Translator::exists()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 1
dl 0
loc 3
ccs 2
cts 2
cp 1
c 1
b 0
f 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
namespace PhpMyAdmin\MoTranslator;
27
28
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
29
use Throwable;
30
use function array_key_exists;
31
use function chr;
32
use function count;
33
use function explode;
34
use function implode;
35
use function intval;
36
use function is_readable;
37
use function ltrim;
38
use function preg_replace;
39
use function rtrim;
40
use function strcmp;
41
use function stripos;
42
use function strpos;
43
use function strtolower;
44
use function substr;
45
use function trim;
46
47
/**
48
 * Provides a simple gettext replacement that works independently from
49
 * the system's gettext abilities.
50
 * It can read MO files and use them for translating strings.
51
 *
52
 * It caches ll strings and translations to speed up the string lookup.
53
 */
54
class Translator
55
{
56
    /**
57
     * None error.
58
     */
59
    public const ERROR_NONE = 0;
60
    /**
61
     * File does not exist.
62
     */
63
    public const ERROR_DOES_NOT_EXIST = 1;
64
    /**
65
     * File has bad magic number.
66
     */
67
    public const ERROR_BAD_MAGIC = 2;
68
    /**
69
     * Error while reading file, probably too short.
70
     */
71
    public const ERROR_READING = 3;
72
73
    /**
74
     * Big endian mo file magic bytes.
75
     */
76
    public const MAGIC_BE = "\x95\x04\x12\xde";
77
    /**
78
     * Little endian mo file magic bytes.
79
     */
80
    public const MAGIC_LE = "\xde\x12\x04\x95";
81
82
    /**
83
     * Parse error code (0 if no error).
84
     *
85
     * @var int
86
     */
87
    public $error = self::ERROR_NONE;
88
89
    /**
90
     * Cache header field for plural forms.
91
     *
92
     * @var string|null
93
     */
94
    private $pluralEquation = null;
95
96
    /** @var ExpressionLanguage|null Evaluator for plurals */
97
    private $pluralExpression = null;
98
99
    /** @var int|null number of plurals */
100
    private $pluralCount = null;
101
102
    /**
103
     * Array with original -> translation mapping.
104
     *
105
     * @var array
106
     */
107
    private $cacheTranslations = [];
108
109
    /**
110
     * @param string $filename Name of mo file to load
111
     */
112 180
    public function __construct(string $filename)
113
    {
114 180
        if (! is_readable($filename)) {
115 24
            $this->error = self::ERROR_DOES_NOT_EXIST;
116
117 24
            return;
118
        }
119
120 156
        $stream = new StringReader($filename);
121
122
        try {
123 156
            $magic = $stream->read(0, 4);
124 152
            if (strcmp($magic, self::MAGIC_LE) === 0) {
125 120
                $unpack = 'V';
126 32
            } elseif (strcmp($magic, self::MAGIC_BE) === 0) {
127 28
                $unpack = 'N';
128
            } else {
129 4
                $this->error = self::ERROR_BAD_MAGIC;
130
131 4
                return;
132
            }
133
134
            /* Parse header */
135 148
            $total = $stream->readint($unpack, 8);
136 148
            $originals = $stream->readint($unpack, 12);
137 136
            $translations = $stream->readint($unpack, 16);
138
139
            /* get original and translations tables */
140 136
            $tableOriginals = $stream->readintarray($unpack, $originals, $total * 2);
141 132
            $tableTranslations = $stream->readintarray($unpack, $translations, $total * 2);
142
143
            /* read all strings to the cache */
144 132
            for ($i = 0; $i < $total; ++$i) {
145 132
                $original = $stream->read($tableOriginals[$i * 2 + 2], $tableOriginals[$i * 2 + 1]);
146 132
                $translation = $stream->read($tableTranslations[$i * 2 + 2], $tableTranslations[$i * 2 + 1]);
147 132
                $this->cacheTranslations[$original] = $translation;
148
            }
149 20
        } catch (ReaderException $e) {
150 20
            $this->error = self::ERROR_READING;
151
152 20
            return;
153
        }
154 132
    }
155
156
    /**
157
     * Translates a string.
158
     *
159
     * @param string $msgid String to be translated
160
     *
161
     * @return string translated string (or original, if not found)
162
     */
163 120
    public function gettext(string $msgid): string
164
    {
165 120
        if (array_key_exists($msgid, $this->cacheTranslations)) {
166 84
            return $this->cacheTranslations[$msgid];
167
        }
168
169 60
        return $msgid;
170
    }
171
172
    /**
173
     * Check if a string is translated.
174
     *
175
     * @param string $msgid String to be checked
176
     */
177 24
    public function exists(string $msgid): bool
178
    {
179 24
        return array_key_exists($msgid, $this->cacheTranslations);
180
    }
181
182
    /**
183
     * Sanitize plural form expression for use in ExpressionLanguage.
184
     *
185
     * @param string $expr Expression to sanitize
186
     *
187
     * @return string sanitized plural form expression
188
     */
189 56
    public static function sanitizePluralExpression(string $expr): string
190
    {
191
        // Parse equation
192 56
        $expr = explode(';', $expr);
193 56
        if (count($expr) >= 2) {
194 44
            $expr = $expr[1];
195
        } else {
196 12
            $expr = $expr[0];
197
        }
198
199 56
        $expr = trim(strtolower($expr));
200
        // Strip plural prefix
201 56
        if (substr($expr, 0, 6) === 'plural') {
202 48
            $expr = ltrim(substr($expr, 6));
203
        }
204
205
        // Strip equals
206 56
        if (substr($expr, 0, 1) === '=') {
207 48
            $expr = ltrim(substr($expr, 1));
208
        }
209
210
        // Cleanup from unwanted chars
211 56
        $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr);
212
213 56
        return (string) $expr;
214
    }
215
216
    /**
217
     * Extracts number of plurals from plurals form expression.
218
     *
219
     * @param string $expr Expression to process
220
     *
221
     * @return int Total number of plurals
222
     */
223 52
    public static function extractPluralCount(string $expr): int
224
    {
225 52
        $parts = explode(';', $expr, 2);
226 52
        $nplurals = explode('=', trim($parts[0]), 2);
227 52
        if (strtolower(rtrim($nplurals[0])) !== 'nplurals') {
228 8
            return 1;
229
        }
230
231 44
        if (count($nplurals) === 1) {
232 4
            return 1;
233
        }
234
235 40
        return intval($nplurals[1]);
236
    }
237
238
    /**
239
     * Parse full PO header and extract only plural forms line.
240
     *
241
     * @param string $header Gettext header
242
     *
243
     * @return string verbatim plural form header field
244
     */
245 44
    public static function extractPluralsForms(string $header): string
246
    {
247 44
        $headers = explode("\n", $header);
248 44
        $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;';
249 44
        foreach ($headers as $header) {
250 44
            if (stripos($header, 'Plural-Forms:') !== 0) {
251 44
                continue;
252
            }
253
254 28
            $expr = substr($header, 13);
255
        }
256
257 44
        return $expr;
258
    }
259
260
    /**
261
     * Get possible plural forms from MO header.
262
     *
263
     * @return string plural form header
264
     */
265 32
    private function getPluralForms(): string
266
    {
267
        // lets assume message number 0 is header
268
        // this is true, right?
269
270
        // cache header field for plural forms
271 32
        if ($this->pluralEquation === null) {
272 28
            if (isset($this->cacheTranslations[''])) {
273 20
                $header = $this->cacheTranslations[''];
274
            } else {
275 8
                $header = '';
276
            }
277
278 28
            $expr = $this->extractPluralsForms($header);
279 28
            $this->pluralEquation = $this->sanitizePluralExpression($expr);
280 28
            $this->pluralCount = $this->extractPluralCount($expr);
281
        }
282
283 32
        return $this->pluralEquation;
284
    }
285
286
    /**
287
     * Detects which plural form to take.
288
     *
289
     * @param int $n count of objects
290
     *
291
     * @return int array index of the right plural form
292
     */
293 32
    private function selectString(int $n): int
294
    {
295 32
        if ($this->pluralExpression === null) {
296 28
            $this->pluralExpression = new ExpressionLanguage();
297
        }
298
299
        try {
300 32
            $plural = $this->pluralExpression->evaluate(
301 32
                $this->getPluralForms(),
302 32
                ['n' => $n]
303
            );
304 4
        } catch (Throwable $e) {
305 4
            $plural = 0;
306
        }
307
308 32
        if ($plural >= $this->pluralCount) {
309 4
            $plural = $this->pluralCount - 1;
310
        }
311
312 32
        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...
313
    }
314
315
    /**
316
     * Plural version of gettext.
317
     *
318
     * @param string $msgid       Single form
319
     * @param string $msgidPlural Plural form
320
     * @param int    $number      Number of objects
321
     *
322
     * @return string translated plural form
323
     */
324 72
    public function ngettext(string $msgid, string $msgidPlural, int $number): string
325
    {
326
        // this should contains all strings separated by NULLs
327 72
        $key = implode(chr(0), [$msgid, $msgidPlural]);
328 72
        if (! array_key_exists($key, $this->cacheTranslations)) {
329 72
            return $number !== 1 ? $msgidPlural : $msgid;
330
        }
331
332
        // find out the appropriate form
333 32
        $select = $this->selectString($number);
334
335 32
        $result = $this->cacheTranslations[$key];
336 32
        $list = explode(chr(0), $result);
337 32
        if ($list === false) {
338
            return '';
339
        }
340
341 32
        if (! isset($list[$select])) {
342 4
            return $list[0];
343
        }
344
345 32
        return $list[$select];
346
    }
347
348
    /**
349
     * Translate with context.
350
     *
351
     * @param string $msgctxt Context
352
     * @param string $msgid   String to be translated
353
     *
354
     * @return string translated plural form
355
     */
356 56
    public function pgettext(string $msgctxt, string $msgid): string
357
    {
358 56
        $key = implode(chr(4), [$msgctxt, $msgid]);
359 56
        $ret = $this->gettext($key);
360 56
        if (strpos($ret, chr(4)) !== false) {
361 24
            return $msgid;
362
        }
363
364 32
        return $ret;
365
    }
366
367
    /**
368
     * Plural version of pgettext.
369
     *
370
     * @param string $msgctxt     Context
371
     * @param string $msgid       Single form
372
     * @param string $msgidPlural Plural form
373
     * @param int    $number      Number of objects
374
     *
375
     * @return string translated plural form
376
     */
377 16
    public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string
378
    {
379 16
        $key = implode(chr(4), [$msgctxt, $msgid]);
380 16
        $ret = $this->ngettext($key, $msgidPlural, $number);
381 16
        if (strpos($ret, chr(4)) !== false) {
382 4
            return $msgid;
383
        }
384
385 12
        return $ret;
386
    }
387
388
    /**
389
     * Set translation in place
390
     *
391
     * @param string $msgid  String to be set
392
     * @param string $msgstr Translation
393
     */
394 4
    public function setTranslation(string $msgid, string $msgstr): void
395
    {
396 4
        $this->cacheTranslations[$msgid] = $msgstr;
397 4
    }
398
}
399