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 = PO::poify($header_string); |
38
|
|
|
if ($this->comments_before_headers) { |
39
|
|
|
$before_headers = PO::prepend_each_line(rtrim( |
40
|
|
|
$this->comments_before_headers)."\n", '# ' |
41
|
|
|
); |
42
|
|
|
} else { |
43
|
|
|
$before_headers = ''; |
44
|
|
|
} |
45
|
|
|
|
46
|
|
|
return rtrim("{$before_headers}msgid \"\"\nmsgstr $poified"); |
47
|
|
|
} |
48
|
|
|
|
49
|
|
|
/** |
50
|
|
|
* Exports all entries to PO format |
51
|
|
|
* |
52
|
|
|
* @return string sequence of mgsgid/msgstr PO strings, doesn't containt |
53
|
|
|
* newline at the end |
54
|
|
|
*/ |
55
|
|
|
public function export_entries() |
56
|
|
|
{ |
57
|
|
|
//TODO: sorting |
58
|
|
|
return implode("\n\n", array_map( |
59
|
|
|
array(__NAMESPACE__.'\PO', 'export_entry'), |
60
|
|
|
$this->entries) |
61
|
|
|
); |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
/** |
65
|
|
|
* Exports the whole PO file as a string |
66
|
|
|
* |
67
|
|
|
* @param bool $include_headers whether to include the headers in the |
68
|
|
|
* export |
69
|
|
|
* @return string ready for inclusion in PO file string for headers and all |
70
|
|
|
* the enrtries |
71
|
|
|
*/ |
72
|
|
|
public function export($include_headers = true) |
73
|
|
|
{ |
74
|
|
|
$res = ''; |
75
|
|
|
if ($include_headers) { |
76
|
|
|
$res .= $this->export_headers(); |
77
|
|
|
$res .= "\n\n"; |
78
|
|
|
} |
79
|
|
|
$res .= $this->export_entries(); |
80
|
|
|
|
81
|
|
|
return $res; |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* Same as {@link export}, but writes the result to a file |
86
|
|
|
* |
87
|
|
|
* @param string $filename where to write the PO string |
88
|
|
|
* @param bool $include_headers whether to include tje headers in the |
89
|
|
|
* export |
90
|
|
|
* @return bool true on success, false on error |
91
|
|
|
*/ |
92
|
|
|
public function export_to_file($filename, $include_headers = true) |
93
|
|
|
{ |
94
|
|
|
$fh = fopen($filename, 'w'); |
95
|
|
|
if (false === $fh) { |
96
|
|
|
return false; |
97
|
|
|
} |
98
|
|
|
$export = $this->export($include_headers); |
99
|
|
|
$res = fwrite($fh, $export); |
100
|
|
|
if (false === $res) { |
101
|
|
|
return false; |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
return fclose($fh); |
105
|
|
|
} |
106
|
|
|
|
107
|
|
|
/** |
108
|
|
|
* Text to include as a comment before the start of the PO contents |
109
|
|
|
* |
110
|
|
|
* Doesn't need to include # in the beginning of lines, these are added |
111
|
|
|
* automatically |
112
|
|
|
* |
113
|
|
|
* @param string $text Comment text |
114
|
|
|
*/ |
115
|
|
|
public function set_comment_before_headers($text) |
116
|
|
|
{ |
117
|
|
|
$this->comments_before_headers = $text; |
118
|
|
|
} |
119
|
|
|
|
120
|
|
|
/** |
121
|
|
|
* Formats a string in PO-style |
122
|
|
|
* |
123
|
|
|
* @param string $string the string to format |
124
|
|
|
* @return string the poified string |
125
|
|
|
*/ |
126
|
|
|
public static function poify($string) |
127
|
|
|
{ |
128
|
|
|
$quote = '"'; |
129
|
|
|
$slash = '\\'; |
130
|
|
|
$newline = "\n"; |
131
|
|
|
|
132
|
|
|
$replaces = array( |
133
|
|
|
"$slash" => "$slash$slash", |
134
|
|
|
"$quote" => "$slash$quote", |
135
|
|
|
"\t" => '\t', |
136
|
|
|
); |
137
|
|
|
|
138
|
|
|
$string = str_replace( |
139
|
|
|
array_keys($replaces), |
140
|
|
|
array_values($replaces), |
141
|
|
|
$string |
142
|
|
|
); |
143
|
|
|
|
144
|
|
|
$po = $quote.implode( |
145
|
|
|
"${slash}n$quote$newline$quote", |
146
|
|
|
explode($newline, $string) |
147
|
|
|
).$quote; |
148
|
|
|
// add empty string on first line for readbility |
149
|
|
|
if (false !== strpos($string, $newline) && |
150
|
|
|
(substr_count($string, $newline) > 1 || |
151
|
|
|
!($newline === substr($string, -strlen($newline))))) { |
152
|
|
|
$po = "$quote$quote$newline$po"; |
153
|
|
|
} |
154
|
|
|
// remove empty strings |
155
|
|
|
$po = str_replace("$newline$quote$quote", '', $po); |
156
|
|
|
|
157
|
|
|
return $po; |
158
|
|
|
} |
159
|
|
|
|
160
|
|
|
/** |
161
|
|
|
* Gives back the original string from a PO-formatted string |
162
|
|
|
* |
163
|
|
|
* @param string $string PO-formatted string |
164
|
|
|
* @return string enascaped string |
165
|
|
|
*/ |
166
|
|
|
public static function unpoify($string) |
167
|
|
|
{ |
168
|
|
|
$escapes = array('t' => "\t", 'n' => "\n", '\\' => '\\'); |
169
|
|
|
$lines = array_map('trim', explode("\n", $string)); |
170
|
|
|
$lines = array_map(array(__NAMESPACE__.'\PO', 'trim_quotes'), $lines); |
171
|
|
|
$unpoified = ''; |
172
|
|
|
$previous_is_backslash = false; |
173
|
|
|
foreach ($lines as $line) { |
174
|
|
|
preg_match_all('/./u', $line, $chars); |
175
|
|
|
$chars = $chars[0]; |
176
|
|
|
foreach ($chars as $char) { |
177
|
|
|
if (!$previous_is_backslash) { |
178
|
|
|
if ('\\' == $char) { |
179
|
|
|
$previous_is_backslash = true; |
180
|
|
|
} else { |
181
|
|
|
$unpoified .= $char; |
182
|
|
|
} |
183
|
|
|
} else { |
184
|
|
|
$previous_is_backslash = false; |
185
|
|
|
$unpoified .= isset($escapes[$char]) ? |
186
|
|
|
$escapes[$char] : |
187
|
|
|
$char; |
188
|
|
|
} |
189
|
|
|
} |
190
|
|
|
} |
191
|
|
|
|
192
|
|
|
return $unpoified; |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
/** |
196
|
|
|
* Inserts $with in the beginning of every new line of $string and |
197
|
|
|
* returns the modified string |
198
|
|
|
* |
199
|
|
|
* @param string $string prepend lines in this string |
200
|
|
|
* @param string $with prepend lines with this string |
201
|
|
|
* @return string The modified string |
202
|
|
|
*/ |
203
|
|
|
public static function prepend_each_line($string, $with) |
204
|
|
|
{ |
205
|
|
|
$php_with = var_export($with, true); |
206
|
|
|
$lines = explode("\n", $string); |
207
|
|
|
// do not prepend the string on the last empty line, artefact by explode |
208
|
|
|
if ("\n" == substr($string, -1)) { |
209
|
|
|
unset($lines[count($lines) - 1]); |
210
|
|
|
} |
211
|
|
|
$res = implode("\n", array_map( |
212
|
|
|
create_function('$x', "return $php_with.\$x;"), |
213
|
|
|
$lines |
214
|
|
|
)); |
215
|
|
|
// give back the empty line, we ignored above |
216
|
|
|
if ("\n" == substr($string, -1)) { |
217
|
|
|
$res .= "\n"; |
218
|
|
|
} |
219
|
|
|
|
220
|
|
|
return $res; |
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
|
|
|
* @param object &$entry the entry to convert to po string |
243
|
|
|
* @return string|bool PO-style formatted string for the entry or |
244
|
|
|
* false if the entry is empty |
245
|
|
|
*/ |
246
|
|
|
public static function export_entry(&$entry) |
247
|
|
|
{ |
248
|
|
|
if (is_null($entry->singular)) { |
249
|
|
|
return false; |
250
|
|
|
} |
251
|
|
|
$po = array(); |
252
|
|
|
if (!empty($entry->translator_comments)) { |
253
|
|
|
$po[] = self::comment_block($entry->translator_comments); |
254
|
|
|
} |
255
|
|
|
if (!empty($entry->extracted_comments)) { |
256
|
|
|
$po[] = self::comment_block($entry->extracted_comments, '.'); |
257
|
|
|
} |
258
|
|
|
if (!empty($entry->references)) { |
259
|
|
|
$po[] = self::comment_block(implode(' ', $entry->references), ':'); |
260
|
|
|
} |
261
|
|
|
if (!empty($entry->flags)) { |
262
|
|
|
$po[] = self::comment_block(implode(", ", $entry->flags), ','); |
263
|
|
|
} |
264
|
|
|
if (!is_null($entry->context)) { |
265
|
|
|
$po[] = 'msgctxt '.self::poify($entry->context); |
266
|
|
|
} |
267
|
|
|
$po[] = 'msgid '.self::poify($entry->singular); |
268
|
|
|
if (!$entry->is_plural) { |
269
|
|
|
$translation = empty($entry->translations) ? |
270
|
|
|
'' : |
271
|
|
|
$entry->translations[0]; |
272
|
|
|
$po[] = 'msgstr '.self::poify($translation); |
273
|
|
|
} else { |
274
|
|
|
$po[] = 'msgid_plural '.self::poify($entry->plural); |
275
|
|
|
$translations = empty($entry->translations) ? |
276
|
|
|
array('', '') : |
277
|
|
|
$entry->translations; |
278
|
|
|
foreach ($translations as $i => $translation) { |
279
|
|
|
$po[] = "msgstr[$i] ".self::poify($translation); |
280
|
|
|
} |
281
|
|
|
} |
282
|
|
|
|
283
|
|
|
return implode("\n", $po); |
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
public function import_from_file($filename) |
287
|
|
|
{ |
288
|
|
|
$f = fopen($filename, 'r'); |
289
|
|
|
if (!$f) { |
290
|
|
|
return false; |
291
|
|
|
} |
292
|
|
|
$lineno = 0; |
293
|
|
|
while (true) { |
294
|
|
|
$res = $this->read_entry($f, $lineno); |
295
|
|
|
if (!$res) { |
|
|
|
|
296
|
|
|
break; |
297
|
|
|
} |
298
|
|
|
if ($res['entry']->singular == '') { |
299
|
|
|
$this->set_headers( |
300
|
|
|
$this->make_headers($res['entry']->translations[0]) |
301
|
|
|
); |
302
|
|
|
} else { |
303
|
|
|
$this->add_entry($res['entry']); |
304
|
|
|
} |
305
|
|
|
} |
306
|
|
|
PO::read_line($f, 'clear'); |
307
|
|
|
if (false === $res) { |
|
|
|
|
308
|
|
|
return false; |
309
|
|
|
} |
310
|
|
|
if (! $this->headers && ! $this->entries) { |
|
|
|
|
311
|
|
|
return false; |
312
|
|
|
} |
313
|
|
|
|
314
|
|
|
return true; |
315
|
|
|
} |
316
|
|
|
|
317
|
|
|
public function read_entry($f, $lineno = 0) |
318
|
|
|
{ |
319
|
|
|
$entry = new EntryTranslations(); |
320
|
|
|
// where were we in the last step |
321
|
|
|
// can be: comment, msgctxt, msgid, msgid_plural, msgstr, msgstr_plural |
322
|
|
|
$context = ''; |
323
|
|
|
$msgstr_index = 0; |
324
|
|
|
$is_final = create_function('$context', 'return $context == "msgstr" || $context == "msgstr_plural";'); |
325
|
|
|
while (true) { |
326
|
|
|
$lineno++; |
327
|
|
|
$line = PO::read_line($f); |
328
|
|
|
if (!$line) { |
329
|
|
|
if (feof($f)) { |
330
|
|
|
if ($is_final($context)) { |
331
|
|
|
break; |
332
|
|
|
} elseif (!$context) {// we haven't read a line and eof came |
333
|
|
|
|
334
|
|
|
return null; |
335
|
|
|
} else { |
336
|
|
|
return false; |
337
|
|
|
} |
338
|
|
|
} else { |
339
|
|
|
return false; |
340
|
|
|
} |
341
|
|
|
} |
342
|
|
|
if ($line == "\n") { |
343
|
|
|
continue; |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
$line = trim($line); |
347
|
|
|
if (preg_match('/^#/', $line, $m)) { |
348
|
|
|
// the comment is the start of a new entry |
349
|
|
|
if ($is_final($context)) { |
350
|
|
|
PO::read_line($f, 'put-back'); |
351
|
|
|
$lineno--; |
352
|
|
|
break; |
353
|
|
|
} |
354
|
|
|
// comments have to be at the beginning |
355
|
|
|
if ($context && $context != 'comment') { |
356
|
|
|
return false; |
357
|
|
|
} |
358
|
|
|
// add comment |
359
|
|
|
$this->add_comment_to_entry($entry, $line); |
360
|
|
|
} elseif (preg_match('/^msgctxt\s+(".*")/', $line, $m)) { |
361
|
|
|
if ($is_final($context)) { |
362
|
|
|
PO::read_line($f, 'put-back'); |
363
|
|
|
$lineno--; |
364
|
|
|
break; |
365
|
|
|
} |
366
|
|
|
if ($context && $context != 'comment') { |
367
|
|
|
return false; |
368
|
|
|
} |
369
|
|
|
$context = 'msgctxt'; |
370
|
|
|
$entry->context .= PO::unpoify($m[1]); |
371
|
|
|
} elseif (preg_match('/^msgid\s+(".*")/', $line, $m)) { |
372
|
|
|
if ($is_final($context)) { |
373
|
|
|
PO::read_line($f, 'put-back'); |
374
|
|
|
$lineno--; |
375
|
|
|
break; |
376
|
|
|
} |
377
|
|
|
if ($context && |
378
|
|
|
$context != 'msgctxt' && |
379
|
|
|
$context != 'comment') { |
380
|
|
|
return false; |
381
|
|
|
} |
382
|
|
|
$context = 'msgid'; |
383
|
|
|
$entry->singular .= PO::unpoify($m[1]); |
384
|
|
|
} elseif (preg_match('/^msgid_plural\s+(".*")/', $line, $m)) { |
385
|
|
|
if ($context != 'msgid') { |
386
|
|
|
return false; |
387
|
|
|
} |
388
|
|
|
$context = 'msgid_plural'; |
389
|
|
|
$entry->is_plural = true; |
390
|
|
|
$entry->plural .= PO::unpoify($m[1]); |
391
|
|
|
} elseif (preg_match('/^msgstr\s+(".*")/', $line, $m)) { |
392
|
|
|
if ($context != 'msgid') { |
393
|
|
|
return false; |
394
|
|
|
} |
395
|
|
|
$context = 'msgstr'; |
396
|
|
|
$entry->translations = array(PO::unpoify($m[1])); |
397
|
|
|
} elseif (preg_match('/^msgstr\[(\d+)\]\s+(".*")/', $line, $m)) { |
398
|
|
|
if ($context != 'msgid_plural' && $context != 'msgstr_plural') { |
399
|
|
|
return false; |
400
|
|
|
} |
401
|
|
|
$context = 'msgstr_plural'; |
402
|
|
|
$msgstr_index = $m[1]; |
403
|
|
|
$entry->translations[$m[1]] = PO::unpoify($m[2]); |
404
|
|
|
} elseif (preg_match('/^".*"$/', $line)) { |
405
|
|
|
$unpoified = PO::unpoify($line); |
406
|
|
|
switch ($context) { |
407
|
|
|
case 'msgid': |
408
|
|
|
$entry->singular .= $unpoified; |
409
|
|
|
break; |
410
|
|
|
case 'msgctxt': |
411
|
|
|
$entry->context .= $unpoified; |
412
|
|
|
break; |
413
|
|
|
case 'msgid_plural': |
414
|
|
|
$entry->plural .= $unpoified; |
415
|
|
|
break; |
416
|
|
|
case 'msgstr': |
417
|
|
|
$entry->translations[0] .= $unpoified; |
418
|
|
|
break; |
419
|
|
|
case 'msgstr_plural': |
420
|
|
|
$entry->translations[$msgstr_index] .= $unpoified; |
421
|
|
|
break; |
422
|
|
|
default: |
423
|
|
|
return false; |
424
|
|
|
} |
425
|
|
|
} else { |
426
|
|
|
return false; |
427
|
|
|
} |
428
|
|
|
} |
429
|
|
|
if (array() == array_filter( |
430
|
|
|
$entry->translations, |
431
|
|
|
create_function('$t', 'return $t || "0" === $t;')) |
432
|
|
|
) { |
433
|
|
|
$entry->translations = array(); |
434
|
|
|
} |
435
|
|
|
|
436
|
|
|
return array('entry' => $entry, 'lineno' => $lineno); |
437
|
|
|
} |
438
|
|
|
|
439
|
|
|
public static function read_line($f, $action = 'read') |
440
|
|
|
{ |
441
|
|
|
static $last_line = ''; |
442
|
|
|
static $use_last_line = false; |
443
|
|
|
if ('clear' == $action) { |
444
|
|
|
$last_line = ''; |
445
|
|
|
|
446
|
|
|
return true; |
447
|
|
|
} |
448
|
|
|
if ('put-back' == $action) { |
449
|
|
|
$use_last_line = true; |
450
|
|
|
|
451
|
|
|
return true; |
452
|
|
|
} |
453
|
|
|
$line = $use_last_line ? $last_line : fgets($f); |
454
|
|
|
$line = ( "\r\n" == substr( $line, -2 ) ) ? |
455
|
|
|
rtrim( $line, "\r\n" ) . "\n" : |
456
|
|
|
$line; |
457
|
|
|
$last_line = $line; |
458
|
|
|
$use_last_line = false; |
459
|
|
|
|
460
|
|
|
return $line; |
461
|
|
|
} |
462
|
|
|
|
463
|
|
|
public function add_comment_to_entry(&$entry, $po_comment_line) |
464
|
|
|
{ |
465
|
|
|
$first_two = substr($po_comment_line, 0, 2); |
466
|
|
|
$comment = trim(substr($po_comment_line, 2)); |
467
|
|
|
if ('#:' == $first_two) { |
468
|
|
|
$entry->references = array_merge( |
469
|
|
|
$entry->references, |
470
|
|
|
preg_split('/\s+/', $comment) |
471
|
|
|
); |
472
|
|
|
} elseif ('#.' == $first_two) { |
473
|
|
|
$entry->extracted_comments = trim( |
474
|
|
|
$entry->extracted_comments . "\n" . $comment |
475
|
|
|
); |
476
|
|
|
} elseif ('#,' == $first_two) { |
477
|
|
|
$entry->flags = array_merge( |
478
|
|
|
$entry->flags, |
479
|
|
|
preg_split('/,\s*/', $comment) |
480
|
|
|
); |
481
|
|
|
} else { |
482
|
|
|
$entry->translator_comments = trim( |
483
|
|
|
$entry->translator_comments . "\n" . $comment |
484
|
|
|
); |
485
|
|
|
} |
486
|
|
|
} |
487
|
|
|
|
488
|
|
|
public static function trim_quotes($s) |
489
|
|
|
{ |
490
|
|
|
if ( substr($s, 0, 1) == '"') { |
491
|
|
|
$s = substr($s, 1); |
492
|
|
|
} |
493
|
|
|
if ( substr($s, -1, 1) == '"') { |
494
|
|
|
$s = substr($s, 0, -1); |
495
|
|
|
} |
496
|
|
|
|
497
|
|
|
return $s; |
498
|
|
|
} |
499
|
|
|
} |
500
|
|
|
|
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.