Passed
Push — fix-php-74 ( 9989b6...f6eb65 )
by Alexander
30:32 queued 19s
created

MessageController::extractMessages()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 12
nc 2
nop 3
dl 0
loc 18
ccs 0
cts 13
cp 0
crap 6
rs 9.8666
c 0
b 0
f 0
1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\console\controllers;
9
10
use Yii;
11
use yii\console\Exception;
12
use yii\console\ExitCode;
13
use yii\db\Connection;
14
use yii\db\Query;
15
use yii\di\Instance;
16
use yii\helpers\Console;
17
use yii\helpers\FileHelper;
18
use yii\helpers\VarDumper;
19
use yii\i18n\GettextPoFile;
20
21
/**
22
 * Extracts messages to be translated from source files.
23
 *
24
 * The extracted messages can be saved the following depending on `format`
25
 * setting in config file:
26
 *
27
 * - PHP message source files.
28
 * - ".po" files.
29
 * - Database.
30
 *
31
 * Usage:
32
 * 1. Create a configuration file using the 'message/config' command:
33
 *    yii message/config /path/to/myapp/messages/config.php
34
 * 2. Edit the created config file, adjusting it for your web application needs.
35
 * 3. Run the 'message/extract' command, using created config:
36
 *    yii message /path/to/myapp/messages/config.php
37
 *
38
 * @author Qiang Xue <[email protected]>
39
 * @since 2.0
40
 */
41
class MessageController extends \yii\console\Controller
42
{
43
    /**
44
     * @var string controller default action ID.
45
     */
46
    public $defaultAction = 'extract';
47
    /**
48
     * @var string required, root directory of all source files.
49
     */
50
    public $sourcePath = '@yii';
51
    /**
52
     * @var string required, root directory containing message translations.
53
     */
54
    public $messagePath = '@yii/messages';
55
    /**
56
     * @var array required, list of language codes that the extracted messages
57
     * should be translated to. For example, ['zh-CN', 'de'].
58
     */
59
    public $languages = [];
60
    /**
61
     * @var string the name of the function for translating messages.
62
     * Defaults to 'Yii::t'. This is used as a mark to find the messages to be
63
     * translated. You may use a string for single function name or an array for
64
     * multiple function names.
65
     */
66
    public $translator = 'Yii::t';
67
    /**
68
     * @var bool whether to sort messages by keys when merging new messages
69
     * with the existing ones. Defaults to false, which means the new (untranslated)
70
     * messages will be separated from the old (translated) ones.
71
     */
72
    public $sort = false;
73
    /**
74
     * @var bool whether the message file should be overwritten with the merged messages
75
     */
76
    public $overwrite = true;
77
    /**
78
     * @var bool whether to remove messages that no longer appear in the source code.
79
     * Defaults to false, which means these messages will NOT be removed.
80
     */
81
    public $removeUnused = false;
82
    /**
83
     * @var bool whether to mark messages that no longer appear in the source code.
84
     * Defaults to true, which means each of these messages will be enclosed with a pair of '@@' marks.
85
     */
86
    public $markUnused = true;
87
    /**
88
     * @var array list of patterns that specify which files/directories should NOT be processed.
89
     * If empty or not set, all files/directories will be processed.
90
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
91
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
92
     */
93
    public $except = [
94
        '.svn',
95
        '.git',
96
        '.gitignore',
97
        '.gitkeep',
98
        '.hgignore',
99
        '.hgkeep',
100
        '/messages',
101
        '/BaseYii.php', // contains examples about Yii::t()
102
    ];
103
    /**
104
     * @var array list of patterns that specify which files (not directories) should be processed.
105
     * If empty or not set, all files will be processed.
106
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
107
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
108
     */
109
    public $only = ['*.php'];
110
    /**
111
     * @var string generated file format. Can be "php", "db", "po" or "pot".
112
     */
113
    public $format = 'php';
114
    /**
115
     * @var string connection component ID for "db" format.
116
     */
117
    public $db = 'db';
118
    /**
119
     * @var string custom name for source message table for "db" format.
120
     */
121
    public $sourceMessageTable = '{{%source_message}}';
122
    /**
123
     * @var string custom name for translation message table for "db" format.
124
     */
125
    public $messageTable = '{{%message}}';
126
    /**
127
     * @var string name of the file that will be used for translations for "po" format.
128
     */
129
    public $catalog = 'messages';
130
    /**
131
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
132
     * @see isCategoryIgnored
133
     */
134
    public $ignoreCategories = [];
135
    /**
136
     * @var string File header in generated PHP file with messages. This property is used only if [[$format]] is "php".
137
     * @since 2.0.13
138
     */
139
    public $phpFileHeader = '';
140
    /**
141
     * @var string|null DocBlock used for messages array in generated PHP file. If `null`, default DocBlock will be used.
142
     * This property is used only if [[$format]] is "php".
143
     * @since 2.0.13
144
     */
145
    public $phpDocBlock;
146
147
    /**
148
     * @var array Config for messages extraction.
149
     * @see actionExtract()
150
     * @see initConfig()
151
     * @since 2.0.13
152
     */
153
    protected $config;
154
155
156
    /**
157
     * {@inheritdoc}
158
     */
159
    public function options($actionID)
160
    {
161
        return array_merge(parent::options($actionID), [
162
            'sourcePath',
163
            'messagePath',
164
            'languages',
165
            'translator',
166
            'sort',
167
            'overwrite',
168
            'removeUnused',
169
            'markUnused',
170
            'except',
171
            'only',
172
            'format',
173
            'db',
174
            'sourceMessageTable',
175
            'messageTable',
176
            'catalog',
177
            'ignoreCategories',
178
            'phpFileHeader',
179
            'phpDocBlock',
180
        ]);
181
    }
182
183
    /**
184
     * {@inheritdoc}
185
     * @since 2.0.8
186
     */
187
    public function optionAliases()
188
    {
189
        return array_merge(parent::optionAliases(), [
190
            'c' => 'catalog',
191
            'e' => 'except',
192
            'f' => 'format',
193
            'i' => 'ignoreCategories',
194
            'l' => 'languages',
195
            'u' => 'markUnused',
196
            'p' => 'messagePath',
197
            'o' => 'only',
198
            'w' => 'overwrite',
199
            'S' => 'sort',
200
            't' => 'translator',
201
            'm' => 'sourceMessageTable',
202
            's' => 'sourcePath',
203
            'r' => 'removeUnused',
204
        ]);
205
    }
206
207
    /**
208
     * Creates a configuration file for the "extract" command using command line options specified.
209
     *
210
     * The generated configuration file contains parameters required
211
     * for source code messages extraction.
212
     * You may use this configuration file with the "extract" command.
213
     *
214
     * @param string $filePath output file name or alias.
215
     * @return int CLI exit code
216
     * @throws Exception on failure.
217
     */
218
    public function actionConfig($filePath)
219
    {
220
        $filePath = Yii::getAlias($filePath);
221
        $dir = dirname($filePath);
222
223
        if (file_exists($filePath)) {
224
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
225
                return ExitCode::OK;
226
            }
227
        }
228
229
        $array = VarDumper::export($this->getOptionValues($this->action->id));
230
        $content = <<<EOD
231
<?php
232
/**
233
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
234
 *
235
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
236
 * It contains parameters for source code messages extraction.
237
 * You may modify this file to suit your needs.
238
 *
239
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
240
 * template configuration file with detailed description for each parameter.
241
 */
242
return $array;
243
244
EOD;
245
246
        if (FileHelper::createDirectory($dir) === false || file_put_contents($filePath, $content, LOCK_EX) === false) {
247
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
248
            return ExitCode::UNSPECIFIED_ERROR;
249
        }
250
251
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
252
        return ExitCode::OK;
253
    }
254
255
    /**
256
     * Creates a configuration file template for the "extract" command.
257
     *
258
     * The created configuration file contains detailed instructions on
259
     * how to customize it to fit for your needs. After customization,
260
     * you may use this configuration file with the "extract" command.
261
     *
262
     * @param string $filePath output file name or alias.
263
     * @return int CLI exit code
264
     * @throws Exception on failure.
265
     */
266
    public function actionConfigTemplate($filePath)
267
    {
268
        $filePath = Yii::getAlias($filePath);
269
270
        if (file_exists($filePath)) {
271
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
272
                return ExitCode::OK;
273
            }
274
        }
275
276
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
277
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
278
            return ExitCode::UNSPECIFIED_ERROR;
279
        }
280
281
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
282
        return ExitCode::OK;
283
    }
284
285
    /**
286
     * Extracts messages to be translated from source code.
287
     *
288
     * This command will search through source code files and extract
289
     * messages that need to be translated in different languages.
290
     *
291
     * @param string $configFile the path or alias of the configuration file.
292
     * You may use the "yii message/config" command to generate
293
     * this file and then customize it for your needs.
294
     * @throws Exception on failure.
295
     */
296
    public function actionExtract($configFile = null)
297
    {
298
        $this->initConfig($configFile);
299
300
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
301
302
        $messages = [];
303
        foreach ($files as $file) {
304
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
305
        }
306
307
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
308
309
        if (in_array($this->config['format'], ['php', 'po'])) {
310
            foreach ($this->config['languages'] as $language) {
311
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
312
                if (!is_dir($dir) && !@mkdir($dir)) {
313
                    throw new Exception("Directory '{$dir}' can not be created.");
314
                }
315
                if ($this->config['format'] === 'po') {
316
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
317
                } else {
318
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
319
                }
320
            }
321
        } elseif ($this->config['format'] === 'db') {
322
            /** @var Connection $db */
323
            $db = Instance::ensure($this->config['db'], Connection::className());
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

323
            $db = Instance::ensure($this->config['db'], /** @scrutinizer ignore-deprecated */ Connection::className());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
324
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
325
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
326
            $this->saveMessagesToDb(
327
                $messages,
328
                $db,
329
                $sourceMessageTable,
330
                $messageTable,
331
                $this->config['removeUnused'],
332
                $this->config['languages'],
333
                $this->config['markUnused']
334
            );
335
        } elseif ($this->config['format'] === 'pot') {
336
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
337
        }
338
    }
339
340
    /**
341
     * Saves messages to database.
342
     *
343
     * @param array $messages
344
     * @param Connection $db
345
     * @param string $sourceMessageTable
346
     * @param string $messageTable
347
     * @param bool $removeUnused
348
     * @param array $languages
349
     * @param bool $markUnused
350
     */
351
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
352
    {
353
        $currentMessages = [];
354
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
355
        foreach ($rows as $row) {
356
            $currentMessages[$row['category']][$row['id']] = $row['message'];
357
        }
358
359
        $currentLanguages = [];
360
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
361
        foreach ($rows as $row) {
362
            $currentLanguages[] = $row['language'];
363
        }
364
        $missingLanguages = [];
365
        if (!empty($currentLanguages)) {
366
            $missingLanguages = array_diff($languages, $currentLanguages);
367
        }
368
369
        $new = [];
370
        $obsolete = [];
371
372
        foreach ($messages as $category => $msgs) {
373
            $msgs = array_unique($msgs);
374
375
            if (isset($currentMessages[$category])) {
376
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
377
                $obsolete += array_diff($currentMessages[$category], $msgs);
378
            } else {
379
                $new[$category] = $msgs;
380
            }
381
        }
382
383
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
384
            $obsolete += $currentMessages[$category];
385
        }
386
387
        if (!$removeUnused) {
388
            foreach ($obsolete as $pk => $msg) {
389
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
390
                    unset($obsolete[$pk]);
391
                }
392
            }
393
        }
394
395
        $obsolete = array_keys($obsolete);
396
        $this->stdout('Inserting new messages...');
397
        $savedFlag = false;
398
399
        foreach ($new as $category => $msgs) {
400
            foreach ($msgs as $msg) {
401
                $savedFlag = true;
402
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
403
                foreach ($languages as $language) {
404
                    $db->createCommand()
405
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
406
                       ->execute();
407
                }
408
            }
409
        }
410
411
        if (!empty($missingLanguages)) {
412
            $updatedMessages = [];
413
            $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
414
            foreach ($rows as $row) {
415
                $updatedMessages[$row['category']][$row['id']] = $row['message'];
416
            }
417
            foreach ($updatedMessages as $category => $msgs) {
418
                foreach ($msgs as $id => $msg) {
419
                    $savedFlag = true;
420
                    foreach ($missingLanguages as $language) {
421
                        $db->createCommand()
422
                            ->insert($messageTable, ['id' => $id, 'language' => $language])
423
                            ->execute();
424
                    }
425
                }
426
            }
427
        }
428
429
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
430
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
431
432
        if (empty($obsolete)) {
433
            $this->stdout("Nothing obsoleted...skipped.\n");
434
            return;
435
        }
436
437
        if ($removeUnused) {
438
            $db->createCommand()
439
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
440
               ->execute();
441
            $this->stdout("deleted.\n");
442
        } elseif ($markUnused) {
443
            $rows = (new Query())
444
                ->select(['id', 'message'])
445
                ->from($sourceMessageTable)
446
                ->where(['in', 'id', $obsolete])
447
                ->all($db);
448
449
            foreach ($rows as $row) {
450
                $db->createCommand()->update(
451
                    $sourceMessageTable,
452
                    ['message' => '@@' . $row['message'] . '@@'],
453
                    ['id' => $row['id']]
454
                )->execute();
455
            }
456
            $this->stdout("updated.\n");
457
        } else {
458
            $this->stdout("kept untouched.\n");
459
        }
460
    }
461
462
    /**
463
     * Extracts messages from a file.
464
     *
465
     * @param string $fileName name of the file to extract messages from
466
     * @param string $translator name of the function used to translate messages
467
     * @param array $ignoreCategories message categories to ignore.
468
     * This parameter is available since version 2.0.4.
469
     * @return array
470
     */
471
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
472
    {
473
        $this->stdout('Extracting messages from ');
474
        $this->stdout($fileName, Console::FG_CYAN);
475
        $this->stdout("...\n");
476
477
        $subject = file_get_contents($fileName);
478
        $messages = [];
479
        $tokens = token_get_all($subject);
480
        foreach ((array) $translator as $currentTranslator) {
481
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
482
            array_shift($translatorTokens);
483
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
484
        }
485
486
        $this->stdout("\n");
487
488
        return $messages;
489
    }
490
491
    /**
492
     * Extracts messages from a parsed PHP tokens list.
493
     * @param array $tokens tokens to be processed.
494
     * @param array $translatorTokens translator tokens.
495
     * @param array $ignoreCategories message categories to ignore.
496
     * @return array messages.
497
     */
498
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
499
    {
500
        $messages = [];
501
        $translatorTokensCount = count($translatorTokens);
502
        $matchedTokensCount = 0;
503
        $buffer = [];
504
        $pendingParenthesisCount = 0;
505
506
        foreach ($tokens as $tokenIndex => $token) {
507
            // finding out translator call
508
            if ($matchedTokensCount < $translatorTokensCount) {
509
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
510
                    $matchedTokensCount++;
511
                } else {
512
                    $matchedTokensCount = 0;
513
                }
514
            } elseif ($matchedTokensCount === $translatorTokensCount) {
515
                // translator found
516
517
                // end of function call
518
                if ($this->tokensEqual(')', $token)) {
519
                    $pendingParenthesisCount--;
520
521
                    if ($pendingParenthesisCount === 0) {
522
                        // end of translator call or end of something that we can't extract
523
                        if (isset($buffer[0][0], $buffer[1], $buffer[2][0]) && $buffer[0][0] === T_CONSTANT_ENCAPSED_STRING && $buffer[1] === ',' && $buffer[2][0] === T_CONSTANT_ENCAPSED_STRING) {
524
                            // is valid call we can extract
525
                            $category = stripcslashes($buffer[0][1]);
526
                            $category = mb_substr($category, 1, -1);
527
528
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
529
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
530
                                $i = 3;
531
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
532
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
533
                                    $i += 2;
534
                                }
535
536
                                $message = stripcslashes($fullMessage);
537
                                $messages[$category][] = $message;
538
                            }
539
540
                            $nestedTokens = array_slice($buffer, 3);
541
                            if (count($nestedTokens) > $translatorTokensCount) {
542
                                // search for possible nested translator calls
543
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
544
                            }
545
                        } else {
546
                            // invalid call or dynamic call we can't extract
547
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
548
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
549
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
550
                        }
551
552
                        // prepare for the next match
553
                        $matchedTokensCount = 0;
554
                        $pendingParenthesisCount = 0;
555
                        $buffer = [];
556
                    } else {
557
                        $buffer[] = $token;
558
                    }
559
                } elseif ($this->tokensEqual('(', $token)) {
560
                    // count beginning of function call, skipping translator beginning
561
562
                    // If we are not yet inside the translator, make sure that it's beginning of the real translator.
563
                    // See https://github.com/yiisoft/yii2/issues/16828
564
                    if ($pendingParenthesisCount === 0) {
565
                        $previousTokenIndex = $tokenIndex - $matchedTokensCount - 1;
566
                        if (is_array($tokens[$previousTokenIndex])) {
567
                            $previousToken = $tokens[$previousTokenIndex][0];
568
                            if (in_array($previousToken, [T_OBJECT_OPERATOR, T_PAAMAYIM_NEKUDOTAYIM], true)) {
569
                                $matchedTokensCount = 0;
570
                                continue;
571
                            }
572
                        }
573
                    }
574
575
                    if ($pendingParenthesisCount > 0) {
576
                        $buffer[] = $token;
577
                    }
578
                    $pendingParenthesisCount++;
579
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
580
                    // ignore comments and whitespaces
581
                    $buffer[] = $token;
582
                }
583
            }
584
        }
585
586
        return $messages;
587
    }
588
589
    /**
590
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
591
     *
592
     * Examples:
593
     *
594
     * - `myapp` - will be ignored only `myapp` category;
595
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
596
     *
597
     * @param string $category category that is checked
598
     * @param array $ignoreCategories message categories to ignore.
599
     * @return bool
600
     * @since 2.0.7
601
     */
602
    protected function isCategoryIgnored($category, array $ignoreCategories)
603
    {
604
        if (!empty($ignoreCategories)) {
605
            if (in_array($category, $ignoreCategories, true)) {
606
                return true;
607
            }
608
            foreach ($ignoreCategories as $pattern) {
609
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
610
                    return true;
611
                }
612
            }
613
        }
614
615
        return false;
616
    }
617
618
    /**
619
     * Finds out if two PHP tokens are equal.
620
     *
621
     * @param array|string $a
622
     * @param array|string $b
623
     * @return bool
624
     * @since 2.0.1
625
     */
626
    protected function tokensEqual($a, $b)
627
    {
628
        if (is_string($a) && is_string($b)) {
629
            return $a === $b;
630
        }
631
        if (isset($a[0], $a[1], $b[0], $b[1])) {
632
            return $a[0] === $b[0] && $a[1] == $b[1];
633
        }
634
635
        return false;
636
    }
637
638
    /**
639
     * Finds out a line of the first non-char PHP token found.
640
     *
641
     * @param array $tokens
642
     * @return int|string
643
     * @since 2.0.1
644
     */
645
    protected function getLine($tokens)
646
    {
647
        foreach ($tokens as $token) {
648
            if (isset($token[2])) {
649
                return $token[2];
650
            }
651
        }
652
653
        return 'unknown';
654
    }
655
656
    /**
657
     * Writes messages into PHP files.
658
     *
659
     * @param array $messages
660
     * @param string $dirName name of the directory to write to
661
     * @param bool $overwrite if existing file should be overwritten without backup
662
     * @param bool $removeUnused if obsolete translations should be removed
663
     * @param bool $sort if translations should be sorted
664
     * @param bool $markUnused if obsolete translations should be marked
665
     */
666
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
667
    {
668
        foreach ($messages as $category => $msgs) {
669
            $file = str_replace('\\', '/', "$dirName/$category.php");
670
            $path = dirname($file);
671
            FileHelper::createDirectory($path);
672
            $msgs = array_values(array_unique($msgs));
673
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
674
            $this->stdout("Saving messages to $coloredFileName...\n");
675
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
676
        }
677
678
        if ($removeUnused) {
679
            $this->deleteUnusedPhpMessageFiles(array_keys($messages), $dirName);
680
        }
681
    }
682
683
    /**
684
     * Writes category messages into PHP file.
685
     *
686
     * @param array $messages
687
     * @param string $fileName name of the file to write to
688
     * @param bool $overwrite if existing file should be overwritten without backup
689
     * @param bool $removeUnused if obsolete translations should be removed
690
     * @param bool $sort if translations should be sorted
691
     * @param string $category message category
692
     * @param bool $markUnused if obsolete translations should be marked
693
     * @return int exit code
694
     */
695
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
696
    {
697
        if (is_file($fileName)) {
698
            $rawExistingMessages = require $fileName;
699
            $existingMessages = $rawExistingMessages;
700
            sort($messages);
701
            ksort($existingMessages);
702
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
703
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
704
                return ExitCode::OK;
705
            }
706
            unset($rawExistingMessages);
707
            $merged = [];
708
            $untranslated = [];
709
            foreach ($messages as $message) {
710
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
711
                    $merged[$message] = $existingMessages[$message];
712
                } else {
713
                    $untranslated[] = $message;
714
                }
715
            }
716
            ksort($merged);
717
            sort($untranslated);
718
            $todo = [];
719
            foreach ($untranslated as $message) {
720
                $todo[$message] = '';
721
            }
722
            ksort($existingMessages);
723
            foreach ($existingMessages as $message => $translation) {
724
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
725
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
726
                        $todo[$message] = $translation;
727
                    } else {
728
                        $todo[$message] = '@@' . $translation . '@@';
729
                    }
730
                }
731
            }
732
            $merged = array_merge($merged, $todo);
733
            if ($sort) {
734
                ksort($merged);
735
            }
736
            if (false === $overwrite) {
737
                $fileName .= '.merged';
738
            }
739
            $this->stdout("Translation merged.\n");
740
        } else {
741
            $merged = [];
742
            foreach ($messages as $message) {
743
                $merged[$message] = '';
744
            }
745
            ksort($merged);
746
        }
747
748
        $array = VarDumper::export($merged);
749
        $content = <<<EOD
750
<?php
751
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
752
return $array;
753
754
EOD;
755
756
        if (file_put_contents($fileName, $content, LOCK_EX) === false) {
757
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
758
            return ExitCode::UNSPECIFIED_ERROR;
759
        }
760
761
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
762
        return ExitCode::OK;
763
    }
764
765
    /**
766
     * Writes messages into PO file.
767
     *
768
     * @param array $messages
769
     * @param string $dirName name of the directory to write to
770
     * @param bool $overwrite if existing file should be overwritten without backup
771
     * @param bool $removeUnused if obsolete translations should be removed
772
     * @param bool $sort if translations should be sorted
773
     * @param string $catalog message catalog
774
     * @param bool $markUnused if obsolete translations should be marked
775
     */
776
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
777
    {
778
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
779
        FileHelper::createDirectory(dirname($file));
780
        $this->stdout("Saving messages to $file...\n");
781
782
        $poFile = new GettextPoFile();
783
784
        $merged = [];
785
        $todos = [];
786
787
        $hasSomethingToWrite = false;
788
        foreach ($messages as $category => $msgs) {
789
            $notTranslatedYet = [];
790
            $msgs = array_values(array_unique($msgs));
791
792
            if (is_file($file)) {
793
                $existingMessages = $poFile->load($file, $category);
794
795
                sort($msgs);
796
                ksort($existingMessages);
797
                if (array_keys($existingMessages) == $msgs) {
798
                    $this->stdout("Nothing new in \"$category\" category...\n");
799
800
                    sort($msgs);
801
                    foreach ($msgs as $message) {
802
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
803
                    }
804
                    ksort($merged);
805
                    continue;
806
                }
807
808
                // merge existing message translations with new message translations
809
                foreach ($msgs as $message) {
810
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
811
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
812
                    } else {
813
                        $notTranslatedYet[] = $message;
814
                    }
815
                }
816
                ksort($merged);
817
                sort($notTranslatedYet);
818
819
                // collect not yet translated messages
820
                foreach ($notTranslatedYet as $message) {
821
                    $todos[$category . chr(4) . $message] = '';
822
                }
823
824
                // add obsolete unused messages
825
                foreach ($existingMessages as $message => $translation) {
826
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
827
                        if (!$markUnused || (!empty($translation) && (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
828
                            $todos[$category . chr(4) . $message] = $translation;
829
                        } else {
830
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
831
                        }
832
                    }
833
                }
834
835
                $merged = array_merge($merged, $todos);
836
                if ($sort) {
837
                    ksort($merged);
838
                }
839
840
                if ($overwrite === false) {
841
                    $file .= '.merged';
842
                }
843
            } else {
844
                sort($msgs);
845
                foreach ($msgs as $message) {
846
                    $merged[$category . chr(4) . $message] = '';
847
                }
848
                ksort($merged);
849
            }
850
            $this->stdout("Category \"$category\" merged.\n");
851
            $hasSomethingToWrite = true;
852
        }
853
        if ($hasSomethingToWrite) {
854
            $poFile->save($file, $merged);
855
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
856
        } else {
857
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
858
        }
859
    }
860
861
    /**
862
     * Writes messages into POT file.
863
     *
864
     * @param array $messages
865
     * @param string $dirName name of the directory to write to
866
     * @param string $catalog message catalog
867
     * @since 2.0.6
868
     */
869
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
870
    {
871
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
872
        FileHelper::createDirectory(dirname($file));
873
        $this->stdout("Saving messages to $file...\n");
874
875
        $poFile = new GettextPoFile();
876
877
        $merged = [];
878
879
        $hasSomethingToWrite = false;
880
        foreach ($messages as $category => $msgs) {
881
            $msgs = array_values(array_unique($msgs));
882
883
            sort($msgs);
884
            foreach ($msgs as $message) {
885
                $merged[$category . chr(4) . $message] = '';
886
            }
887
            $this->stdout("Category \"$category\" merged.\n");
888
            $hasSomethingToWrite = true;
889
        }
890
        if ($hasSomethingToWrite) {
891
            ksort($merged);
892
            $poFile->save($file, $merged);
893
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
894
        } else {
895
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
896
        }
897
    }
898
899
    private function deleteUnusedPhpMessageFiles($existingCategories, $dirName)
900
    {
901
        $messageFiles = FileHelper::findFiles($dirName);
902
        foreach ($messageFiles as $messageFile) {
903
            $categoryFileName = str_replace($dirName, '', $messageFile);
904
            $categoryFileName = ltrim($categoryFileName, DIRECTORY_SEPARATOR);
905
            $category = preg_replace('#\.php$#', '', $categoryFileName);
906
            $category = str_replace(DIRECTORY_SEPARATOR, '/', $category);
907
908
            if (!in_array($category, $existingCategories, true)) {
909
                unlink($messageFile);
910
            }
911
        }
912
    }
913
914
    /**
915
     * @param string $configFile
916
     * @throws Exception If configuration file does not exists.
917
     * @since 2.0.13
918
     */
919
    protected function initConfig($configFile)
920
    {
921
        $configFileContent = [];
922
        if ($configFile !== null) {
0 ignored issues
show
introduced by
The condition $configFile !== null is always true.
Loading history...
923
            $configFile = Yii::getAlias($configFile);
924
            if (!is_file($configFile)) {
925
                throw new Exception("The configuration file does not exist: $configFile");
926
            }
927
            $configFileContent = require $configFile;
928
        }
929
930
        $this->config = array_merge(
931
            $this->getOptionValues($this->action->id),
932
            $configFileContent,
933
            $this->getPassedOptionValues()
934
        );
935
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
936
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
937
938
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
939
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
940
        }
941
        if (!is_dir($this->config['sourcePath'])) {
942
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
943
        }
944
        if (empty($this->config['format']) || !in_array($this->config['format'], ['php', 'po', 'pot', 'db'])) {
945
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
946
        }
947
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
948
            if (!isset($this->config['messagePath'])) {
949
                throw new Exception('The configuration file must specify "messagePath".');
950
            }
951
            if (!is_dir($this->config['messagePath'])) {
952
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
953
            }
954
        }
955
        if (empty($this->config['languages'])) {
956
            throw new Exception('Languages cannot be empty.');
957
        }
958
959
        if ($this->config['format'] === 'php' && $this->config['phpDocBlock'] === null) {
960
            $this->config['phpDocBlock'] = <<<DOCBLOCK
961
/**
962
 * Message translations.
963
 *
964
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
965
 * It contains the localizable messages extracted from source code.
966
 * You may modify this file by translating the extracted messages.
967
 *
968
 * Each array element represents the translation (value) of a message (key).
969
 * If the value is empty, the message is considered as not translated.
970
 * Messages that no longer need translation will have their translations
971
 * enclosed between a pair of '@@' marks.
972
 *
973
 * Message string can be used with plural forms format. Check i18n section
974
 * of the guide for details.
975
 *
976
 * NOTE: this file must be saved in UTF-8 encoding.
977
 */
978
DOCBLOCK;
979
        }
980
    }
981
}
982