Completed
Pull Request — master (#2252)
by ྅༻ Ǭɀħ
03:57
created

PO::import_from_file()   C

Complexity

Conditions 8
Paths 7

Size

Total Lines 30
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 20
nc 7
nop 1
dl 0
loc 30
rs 5.3846
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file is part of the POMO package.
4
 *
5
 * @copyright 2014 POMO
6
 * @license GPL
7
 */
8
9
namespace POMO;
10
11
use POMO\Translations\GettextTranslations;
12
use POMO\Translations\EntryTranslations;
13
14
ini_set('auto_detect_line_endings', 1);
15
16
/**
17
 * Class for working with PO files
18
 */
19
class PO extends GettextTranslations
20
{
21
    const MAX_LINE_LEN = 79;
22
23
    public $comments_before_headers = '';
24
25
    /**
26
     * Exports headers to a PO entry
27
     *
28
     * @return string msgid/msgstr PO entry for this PO file headers, doesn't
29
     *                contain newline at the end
30
     */
31
    public function export_headers()
32
    {
33
        $header_string = '';
34
        foreach ($this->headers as $header => $value) {
35
            $header_string.= "$header: $value\n";
36
        }
37
        $poified = self::poify($header_string);
38
        if ($this->comments_before_headers) {
39
            $before_headers = self::prepend_each_line(
40
                rtrim($this->comments_before_headers)."\n",
41
                '# '
42
            );
43
        } else {
44
            $before_headers = '';
45
        }
46
47
        return rtrim("{$before_headers}msgid \"\"\nmsgstr $poified");
48
    }
49
50
    /**
51
     * Exports all entries to PO format
52
     *
53
     * @return string sequence of mgsgid/msgstr PO strings, doesn't containt
54
     *                newline at the end
55
     */
56
    public function export_entries()
57
    {
58
        //TODO: sorting
59
        return implode("\n\n", array_map(
60
            array(__NAMESPACE__.'\PO', 'export_entry'),
61
            $this->entries
62
        ));
63
    }
64
65
    /**
66
     * Exports the whole PO file as a string
67
     *
68
     * @param  bool   $include_headers whether to include the headers in the
69
     *                                 export
70
     * @return string ready for inclusion in PO file string for headers and all
71
     *                                the enrtries
72
     */
73
    public function export($include_headers = true)
74
    {
75
        $res = '';
76
        if ($include_headers) {
77
            $res .= $this->export_headers();
78
            $res .= "\n\n";
79
        }
80
        $res .= $this->export_entries();
81
82
        return $res;
83
    }
84
85
    /**
86
     * Same as {@link export}, but writes the result to a file
87
     *
88
     * @param  string $filename        where to write the PO string
89
     * @param  bool   $include_headers whether to include tje headers in the
90
     *                                 export
91
     * @return bool   true on success, false on error
92
     */
93
    public function export_to_file($filename, $include_headers = true)
94
    {
95
        $fh = fopen($filename, 'w');
96
        if (false === $fh) {
97
            return false;
98
        }
99
        $export = $this->export($include_headers);
100
        $res = fwrite($fh, $export);
101
        if (false === $res) {
102
            return false;
103
        }
104
105
        return fclose($fh);
106
    }
107
108
    /**
109
     * Text to include as a comment before the start of the PO contents
110
     *
111
     * Doesn't need to include # in the beginning of lines, these are added
112
     * automatically
113
     *
114
     * @param string $text Comment text
115
     */
116
    public function set_comment_before_headers($text)
117
    {
118
        $this->comments_before_headers = $text;
119
    }
120
121
    /**
122
     * Formats a string in PO-style
123
     *
124
     * @param  string $string the string to format
125
     * @return string the poified string
126
     */
127
    public static function poify($string)
128
    {
129
        $quote = '"';
130
        $slash = '\\';
131
        $newline = "\n";
132
133
        $replaces = array(
134
            "$slash"    => "$slash$slash",
135
            "$quote"    => "$slash$quote",
136
            "\t"        => '\t',
137
        );
138
139
        $string = str_replace(
140
            array_keys($replaces),
141
            array_values($replaces),
142
            $string
143
        );
144
145
        $po = $quote.implode(
146
            "${slash}n$quote$newline$quote",
147
            explode($newline, $string)
148
        ).$quote;
149
        // add empty string on first line for readbility
150
        if (false !== strpos($string, $newline) &&
151
                (substr_count($string, $newline) > 1 ||
152
                !($newline === substr($string, -strlen($newline))))) {
153
            $po = "$quote$quote$newline$po";
154
        }
155
        // remove empty strings
156
        $po = str_replace("$newline$quote$quote", '', $po);
157
158
        return $po;
159
    }
160
161
    /**
162
     * Gives back the original string from a PO-formatted string
163
     *
164
     * @param  string $string PO-formatted string
165
     * @return string enascaped string
166
     */
167
    public static function unpoify($string)
168
    {
169
        $escapes = array('t' => "\t", 'n' => "\n", 'r' => "\r", '\\' => '\\');
170
        $lines = array_map('trim', explode("\n", $string));
171
        $lines = array_map(array(__NAMESPACE__.'\PO', 'trim_quotes'), $lines);
172
        $unpoified = '';
173
        $previous_is_backslash = false;
174
        foreach ($lines as $line) {
175
            preg_match_all('/./u', $line, $chars);
176
            $chars = $chars[0];
177
            foreach ($chars as $char) {
178
                if (!$previous_is_backslash) {
179
                    if ('\\' == $char) {
180
                        $previous_is_backslash = true;
181
                    } else {
182
                        $unpoified .= $char;
183
                    }
184
                } else {
185
                    $previous_is_backslash = false;
186
                    $unpoified .= isset($escapes[$char])? $escapes[$char] : $char;
187
                }
188
            }
189
        }
190
191
        // Standardise the line endings on imported content, technically PO files shouldn't contain \r
192
        $unpoified = str_replace(array( "\r\n", "\r" ), "\n", $unpoified);
193
194
        return $unpoified;
195
    }
196
197
    /**
198
     * Inserts $with in the beginning of every new line of $string and
199
     * returns the modified string
200
     *
201
     * @param  string $string prepend lines in this string
202
     * @param  string $with   prepend lines with this string
203
     * @return string The modified string
204
     */
205
    public static function prepend_each_line($string, $with)
206
    {
207
        $lines = explode("\n", $string);
208
        $append = '';
209
        if ("\n" === substr($string, -1) && '' === end($lines)) {
210
            // Last line might be empty because $string was terminated
211
            // with a newline, remove it from the $lines array,
212
            // we'll restore state by re-terminating the string at the end
213
            array_pop($lines);
214
            $append = "\n";
215
        }
216
        foreach ($lines as &$line) {
217
            $line = $with . $line;
218
        }
219
        unset($line);
220
        return implode("\n", $lines) . $append;
221
    }
222
223
    /**
224
     * Prepare a text as a comment -- wraps the lines and prepends #
225
     * and a special character to each line
226
     *
227
     * @param  string $text the comment text
228
     * @param  string $char character to denote a special PO comment,
229
     *                      like :, default is a space
230
     * @return string The modified string
231
     */
232
    private static function comment_block($text, $char = ' ')
233
    {
234
        $text = wordwrap($text, self::MAX_LINE_LEN - 3);
235
236
        return self::prepend_each_line($text, "#$char ");
237
    }
238
239
    /**
240
     * Builds a string from the entry for inclusion in PO file
241
     *
242
     * @static
243
     * @param EntryTranslations &$entry the entry to convert to po string
244
     * @return false|string PO-style formatted string for the entry or
245
     *     false if the entry is empty
246
     */
247
    public static function export_entry(EntryTranslations &$entry)
248
    {
249
        if (null === $entry->singular || '' === $entry->singular) {
250
            return false;
251
        }
252
        $po = array();
253
        if (!empty($entry->translator_comments)) {
254
            $po[] = self::comment_block($entry->translator_comments);
255
        }
256
        if (!empty($entry->extracted_comments)) {
257
            $po[] = self::comment_block($entry->extracted_comments, '.');
258
        }
259
        if (!empty($entry->references)) {
260
            $po[] = self::comment_block(implode(' ', $entry->references), ':');
261
        }
262
        if (!empty($entry->flags)) {
263
            $po[] = self::comment_block(implode(", ", $entry->flags), ',');
264
        }
265
        if (!is_null($entry->context)) {
266
            $po[] = 'msgctxt '.self::poify($entry->context);
267
        }
268
        $po[] = 'msgid '.self::poify($entry->singular);
269
        if (!$entry->is_plural) {
270
            $translation = empty($entry->translations) ?
271
                '' :
272
                $entry->translations[0];
273
            $translation = self::match_begin_and_end_newlines($translation, $entry->singular);
274
            $po[] = 'msgstr '.self::poify($translation);
275
        } else {
276
            $po[] = 'msgid_plural '.self::poify($entry->plural);
277
            $translations = empty($entry->translations) ?
278
                array('', '') :
279
                $entry->translations;
280
            foreach ($translations as $i => $translation) {
281
                $translation = self::match_begin_and_end_newlines($translation, $entry->plural);
282
                $po[] = "msgstr[$i] ".self::poify($translation);
283
            }
284
        }
285
286
        return implode("\n", $po);
287
    }
288
289
    /**
290
     * @param $translation
291
     * @param $original
292
     * @return string
293
     */
294
    public static function match_begin_and_end_newlines($translation, $original)
295
    {
296
        if ('' === $translation) {
297
            return $translation;
298
        }
299
300
        $original_begin = "\n" === substr($original, 0, 1);
301
        $original_end = "\n" === substr($original, -1);
302
        $translation_begin = "\n" === substr($translation, 0, 1);
303
        $translation_end = "\n" === substr($translation, -1);
304
        if ($original_begin) {
305
            if (! $translation_begin) {
306
                $translation = "\n" . $translation;
307
            }
308
        } elseif ($translation_begin) {
309
            $translation = ltrim($translation, "\n");
310
        }
311
        if ($original_end) {
312
            if (! $translation_end) {
313
                $translation .= "\n";
314
            }
315
        } elseif ($translation_end) {
316
            $translation = rtrim($translation, "\n");
317
        }
318
        return $translation;
319
    }
320
321
    /**
322
     * @param string $filename
323
     * @return boolean
324
     */
325
    public function import_from_file($filename)
326
    {
327
        $f = fopen($filename, 'r');
328
        if (!$f) {
329
            return false;
330
        }
331
        $lineno = 0;
332
        while (true) {
333
            $res = $this->read_entry($f, $lineno);
334
            if (!$res) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $res of type array<string,POMO\Transl...ryTranslations|integer> is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
335
                break;
336
            }
337
            if ($res['entry']->singular == '') {
338
                $this->set_headers(
339
                    $this->make_headers($res['entry']->translations[0])
340
                );
341
            } else {
342
                $this->add_entry($res['entry']);
343
            }
344
        }
345
        self::read_line($f, 'clear');
346
        if (false === $res) {
0 ignored issues
show
Bug introduced by
The variable $res does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
347
            return false;
348
        }
349
        if (! $this->headers && ! $this->entries) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->headers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->entries of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
350
            return false;
351
        }
352
353
        return true;
354
    }
355
356
    /**
357
     * Helper function for read_entry
358
     * @param string $context
359
     * @return bool
360
     */
361
    protected static function is_final($context)
362
    {
363
        return ($context === 'msgstr') || ($context === 'msgstr_plural');
364
    }
365
366
    /**
367
     * @param resource $f
368
     * @param int      $lineno
369
     * @return null|false|array
370
     */
371
    public function read_entry($f, $lineno = 0)
372
    {
373
        $entry = new EntryTranslations();
374
        // where were we in the last step
375
        // can be: comment, msgctxt, msgid, msgid_plural, msgstr, msgstr_plural
376
        $context = '';
377
        $msgstr_index = 0;
378
        while (true) {
379
            $lineno++;
380
            $line = self::read_line($f);
381
            if (!$line) {
382
                if (feof($f)) {
383
                    if (self::is_final($context)) {
384
                        break;
385
                    } elseif (!$context) { // we haven't read a line and eof came
386
                        return null;
387
                    } else {
388
                        return false;
389
                    }
390
                } else {
391
                    return false;
392
                }
393
            }
394
            if ($line == "\n") {
395
                continue;
396
            }
397
398
            $line = trim($line);
399
            if (preg_match('/^#/', $line, $m)) {
400
                // the comment is the start of a new entry
401
                if (self::is_final($context)) {
402
                    self::read_line($f, 'put-back');
403
                    $lineno--;
404
                    break;
405
                }
406
                // comments have to be at the beginning
407
                if ($context && $context != 'comment') {
408
                    return false;
409
                }
410
                // add comment
411
                $this->add_comment_to_entry($entry, $line);
412
            } elseif (preg_match('/^msgctxt\s+(".*")/', $line, $m)) {
413
                if (self::is_final($context)) {
414
                    self::read_line($f, 'put-back');
415
                    $lineno--;
416
                    break;
417
                }
418
                if ($context && $context != 'comment') {
419
                    return false;
420
                }
421
                $context = 'msgctxt';
422
                $entry->context .= self::unpoify($m[1]);
423
            } elseif (preg_match('/^msgid\s+(".*")/', $line, $m)) {
424
                if (self::is_final($context)) {
425
                    self::read_line($f, 'put-back');
426
                    $lineno--;
427
                    break;
428
                }
429
                if ($context &&
430
                    $context != 'msgctxt' &&
431
                    $context != 'comment') {
432
                    return false;
433
                }
434
                $context = 'msgid';
435
                $entry->singular .= self::unpoify($m[1]);
436
            } elseif (preg_match('/^msgid_plural\s+(".*")/', $line, $m)) {
437
                if ($context != 'msgid') {
438
                    return false;
439
                }
440
                $context = 'msgid_plural';
441
                $entry->is_plural = true;
442
                $entry->plural .= self::unpoify($m[1]);
443
            } elseif (preg_match('/^msgstr\s+(".*")/', $line, $m)) {
444
                if ($context != 'msgid') {
445
                    return false;
446
                }
447
                $context = 'msgstr';
448
                $entry->translations = array(self::unpoify($m[1]));
449
            } elseif (preg_match('/^msgstr\[(\d+)\]\s+(".*")/', $line, $m)) {
450
                if ($context != 'msgid_plural' && $context != 'msgstr_plural') {
451
                    return false;
452
                }
453
                $context = 'msgstr_plural';
454
                $msgstr_index = $m[1];
455
                $entry->translations[$m[1]] = self::unpoify($m[2]);
456
            } elseif (preg_match('/^".*"$/', $line)) {
457
                $unpoified = self::unpoify($line);
458
                switch ($context) {
459
                    case 'msgid':
460
                        $entry->singular .= $unpoified;
461
                        break;
462
                    case 'msgctxt':
463
                        $entry->context .= $unpoified;
464
                        break;
465
                    case 'msgid_plural':
466
                        $entry->plural .= $unpoified;
467
                        break;
468
                    case 'msgstr':
469
                        $entry->translations[0] .= $unpoified;
470
                        break;
471
                    case 'msgstr_plural':
472
                        $entry->translations[$msgstr_index] .= $unpoified;
473
                        break;
474
                    default:
475
                        return false;
476
                }
477
            } else {
478
                return false;
479
            }
480
        }
481
482
        $have_translations = false;
483
        foreach ($entry->translations as $t) {
484
            if ($t || ('0' === $t)) {
485
                $have_translations = true;
486
                break;
487
            }
488
        }
489
        if (false === $have_translations) {
490
            $entry->translations = array();
491
        }
492
493
        return array('entry' => $entry, 'lineno' => $lineno);
494
    }
495
496
    /**
497
     * @param     resource $f
498
     * @param     string   $action
499
     * @return boolean
500
     */
501
    public static function read_line($f, $action = 'read')
502
    {
503
        static $last_line = '';
504
        static $use_last_line = false;
505
        if ('clear' == $action) {
506
            $last_line = '';
507
508
            return true;
509
        }
510
        if ('put-back' == $action) {
511
            $use_last_line = true;
512
513
            return true;
514
        }
515
        $line = $use_last_line ? $last_line : fgets($f);
516
        $line = ("\r\n" == substr($line, -2)) ?
517
            rtrim($line, "\r\n") . "\n" :
518
            $line;
519
        $last_line = $line;
520
        $use_last_line = false;
521
522
        return $line;
523
    }
524
525
    /**
526
     * @param EntryTranslations $entry
527
     * @param string            $po_comment_line
528
     */
529
    public function add_comment_to_entry(EntryTranslations &$entry, $po_comment_line)
530
    {
531
        $first_two = substr($po_comment_line, 0, 2);
532
        $comment = trim(substr($po_comment_line, 2));
533
        if ('#:' == $first_two) {
534
            $entry->references = array_merge(
535
                $entry->references,
536
                preg_split('/\s+/', $comment)
537
            );
538
        } elseif ('#.' == $first_two) {
539
            $entry->extracted_comments = trim(
540
                $entry->extracted_comments . "\n" . $comment
541
            );
542
        } elseif ('#,' == $first_two) {
543
            $entry->flags = array_merge(
544
                $entry->flags,
545
                preg_split('/,\s*/', $comment)
546
            );
547
        } else {
548
            $entry->translator_comments = trim(
549
                $entry->translator_comments . "\n" . $comment
550
            );
551
        }
552
    }
553
554
    /**
555
     * @param string $s
556
     * @return string
557
     */
558
    public static function trim_quotes($s)
559
    {
560
        if (substr($s, 0, 1) == '"') {
561
            $s = substr($s, 1);
562
        }
563
        if (substr($s, -1, 1) == '"') {
564
            $s = substr($s, 0, -1);
565
        }
566
567
        return $s;
568
    }
569
}
570