Passed
Push — fix-php-74 ( f6eb65...a1ad6b )
by Alexander
38:08 queued 12:19
created

MessageController::saveMessagesToPHP()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 4
nop 6
dl 0
loc 14
ccs 11
cts 11
cp 1
crap 3
rs 9.9332
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 68
    public function options($actionID)
160
    {
161 68
        return array_merge(parent::options($actionID), [
162 68
            '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 6
    public function actionConfig($filePath)
219
    {
220 6
        $filePath = Yii::getAlias($filePath);
221 6
        $dir = dirname($filePath);
222
223 6
        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 6
        $array = VarDumper::export($this->getOptionValues($this->action->id));
230
        $content = <<<EOD
231
<?php
232
/**
233 6
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
234
 *
235 6
 * 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 6
 * 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 6
return $array;
243
244
EOD;
245
246 6
        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 6
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
252 6
        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 62
    public function actionExtract($configFile = null)
297
    {
298 62
        $this->initConfig($configFile);
299
300 59
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
301
302 59
        $messages = [];
303 59
        foreach ($files as $file) {
304 53
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
305
        }
306
307 59
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
308
309 59
        if (in_array($this->config['format'], ['php', 'po'])) {
310 45
            foreach ($this->config['languages'] as $language) {
311 45
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
312 45
                if (!is_dir($dir) && !@mkdir($dir)) {
313
                    throw new Exception("Directory '{$dir}' can not be created.");
314
                }
315 45
                if ($this->config['format'] === 'po') {
316 16
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
317
                } else {
318 45
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
319
                }
320
            }
321 14
        } elseif ($this->config['format'] === 'db') {
322
            /** @var Connection $db */
323 14
            $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 14
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
325 14
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
326 14
            $this->saveMessagesToDb(
327 14
                $messages,
328 14
                $db,
329 14
                $sourceMessageTable,
330 14
                $messageTable,
331 14
                $this->config['removeUnused'],
332 14
                $this->config['languages'],
333 14
                $this->config['markUnused']
334
            );
335
        } elseif ($this->config['format'] === 'pot') {
336
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
337
        }
338 59
    }
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 14
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
352
    {
353 14
        $currentMessages = [];
354 14
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
355 14
        foreach ($rows as $row) {
356 13
            $currentMessages[$row['category']][$row['id']] = $row['message'];
357
        }
358
359 14
        $currentLanguages = [];
360 14
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
361 14
        foreach ($rows as $row) {
362 13
            $currentLanguages[] = $row['language'];
363
        }
364 14
        $missingLanguages = [];
365 14
        if (!empty($currentLanguages)) {
366 13
            $missingLanguages = array_diff($languages, $currentLanguages);
367
        }
368
369 14
        $new = [];
370 14
        $obsolete = [];
371
372 14
        foreach ($messages as $category => $msgs) {
373 14
            $msgs = array_unique($msgs);
374
375 14
            if (isset($currentMessages[$category])) {
376 9
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
377 9
                $obsolete += array_diff($currentMessages[$category], $msgs);
378
            } else {
379 14
                $new[$category] = $msgs;
380
            }
381
        }
382
383 14
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
384 9
            $obsolete += $currentMessages[$category];
385
        }
386
387 14
        if (!$removeUnused) {
388 13
            foreach ($obsolete as $pk => $msg) {
389 10
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
390 10
                    unset($obsolete[$pk]);
391
                }
392
            }
393
        }
394
395 14
        $obsolete = array_keys($obsolete);
396 14
        $this->stdout('Inserting new messages...');
397 14
        $savedFlag = false;
398
399 14
        foreach ($new as $category => $msgs) {
400 14
            foreach ($msgs as $msg) {
401 12
                $savedFlag = true;
402 12
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
403 12
                foreach ($languages as $language) {
404 12
                    $db->createCommand()
405 12
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
406 14
                       ->execute();
407
                }
408
            }
409
        }
410
411 14
        if (!empty($missingLanguages)) {
412 1
            $updatedMessages = [];
413 1
            $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
414 1
            foreach ($rows as $row) {
415 1
                $updatedMessages[$row['category']][$row['id']] = $row['message'];
416
            }
417 1
            foreach ($updatedMessages as $category => $msgs) {
418 1
                foreach ($msgs as $id => $msg) {
419 1
                    $savedFlag = true;
420 1
                    foreach ($missingLanguages as $language) {
421 1
                        $db->createCommand()
422 1
                            ->insert($messageTable, ['id' => $id, 'language' => $language])
423 1
                            ->execute();
424
                    }
425
                }
426
            }
427
        }
428
429 14
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
430 14
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
431
432 14
        if (empty($obsolete)) {
433 6
            $this->stdout("Nothing obsoleted...skipped.\n");
434 6
            return;
435
        }
436
437 11
        if ($removeUnused) {
438 1
            $db->createCommand()
439 1
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
440 1
               ->execute();
441 1
            $this->stdout("deleted.\n");
442 10
        } elseif ($markUnused) {
443 8
            $rows = (new Query())
444 8
                ->select(['id', 'message'])
445 8
                ->from($sourceMessageTable)
446 8
                ->where(['in', 'id', $obsolete])
447 8
                ->all($db);
448
449 8
            foreach ($rows as $row) {
450 8
                $db->createCommand()->update(
451 8
                    $sourceMessageTable,
452 8
                    ['message' => '@@' . $row['message'] . '@@'],
453 8
                    ['id' => $row['id']]
454 8
                )->execute();
455
            }
456 8
            $this->stdout("updated.\n");
457
        } else {
458 2
            $this->stdout("kept untouched.\n");
459
        }
460 11
    }
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 53
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
472
    {
473 53
        $this->stdout('Extracting messages from ');
474 53
        $this->stdout($fileName, Console::FG_CYAN);
475 53
        $this->stdout("...\n");
476
477 53
        $subject = file_get_contents($fileName);
478 53
        $messages = [];
479 53
        $tokens = token_get_all($subject);
480 53
        foreach ((array) $translator as $currentTranslator) {
481 53
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
482 53
            array_shift($translatorTokens);
483 53
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
484
        }
485
486 53
        $this->stdout("\n");
487
488 53
        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 53
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
499
    {
500 53
        $messages = [];
501 53
        $translatorTokensCount = count($translatorTokens);
502 53
        $matchedTokensCount = 0;
503 53
        $buffer = [];
504 53
        $pendingParenthesisCount = 0;
505
506 53
        foreach ($tokens as $tokenIndex => $token) {
507
            // finding out translator call
508 53
            if ($matchedTokensCount < $translatorTokensCount) {
509 53
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
510 53
                    $matchedTokensCount++;
511
                } else {
512 53
                    $matchedTokensCount = 0;
513
                }
514 53
            } elseif ($matchedTokensCount === $translatorTokensCount) {
515
                // translator found
516
517
                // end of function call
518 53
                if ($this->tokensEqual(')', $token)) {
519 53
                    $pendingParenthesisCount--;
520
521 53
                    if ($pendingParenthesisCount === 0) {
522
                        // end of translator call or end of something that we can't extract
523 53
                        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 53
                            $category = stripcslashes($buffer[0][1]);
526 53
                            $category = mb_substr($category, 1, -1);
527
528 53
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
529 53
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
530 53
                                $i = 3;
531 53
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
532 3
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
533 3
                                    $i += 2;
534
                                }
535
536 53
                                $message = stripcslashes($fullMessage);
537 53
                                $messages[$category][] = $message;
538
                            }
539
540 53
                            $nestedTokens = array_slice($buffer, 3);
541 53
                            if (count($nestedTokens) > $translatorTokensCount) {
542
                                // search for possible nested translator calls
543 53
                                $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 53
                        $matchedTokensCount = 0;
554 53
                        $pendingParenthesisCount = 0;
555 53
                        $buffer = [];
556
                    } else {
557 53
                        $buffer[] = $token;
558
                    }
559 53
                } 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 53
                    if ($pendingParenthesisCount === 0) {
565 53
                        $previousTokenIndex = $tokenIndex - $matchedTokensCount - 1;
566 53
                        if (is_array($tokens[$previousTokenIndex])) {
567 53
                            $previousToken = $tokens[$previousTokenIndex][0];
568 53
                            if (in_array($previousToken, [T_OBJECT_OPERATOR, T_PAAMAYIM_NEKUDOTAYIM], true)) {
569 3
                                $matchedTokensCount = 0;
570 3
                                continue;
571
                            }
572
                        }
573
                    }
574
575 53
                    if ($pendingParenthesisCount > 0) {
576 6
                        $buffer[] = $token;
577
                    }
578 53
                    $pendingParenthesisCount++;
579 53
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
580
                    // ignore comments and whitespaces
581 53
                    $buffer[] = $token;
582
                }
583
            }
584
        }
585
586 53
        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 53
    protected function isCategoryIgnored($category, array $ignoreCategories)
603
    {
604 53
        if (!empty($ignoreCategories)) {
605 3
            if (in_array($category, $ignoreCategories, true)) {
606 3
                return true;
607
            }
608 3
            foreach ($ignoreCategories as $pattern) {
609 3
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
610 3
                    return true;
611
                }
612
            }
613
        }
614
615 53
        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 53
    protected function tokensEqual($a, $b)
627
    {
628 53
        if (is_string($a) && is_string($b)) {
629 53
            return $a === $b;
630
        }
631 53
        if (isset($a[0], $a[1], $b[0], $b[1])) {
632 53
            return $a[0] === $b[0] && $a[1] == $b[1];
633
        }
634
635 53
        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 29
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
667
    {
668 29
        foreach ($messages as $category => $msgs) {
669 23
            $file = str_replace('\\', '/', "$dirName/$category.php");
670 23
            $path = dirname($file);
671 23
            FileHelper::createDirectory($path);
672 23
            $msgs = array_values(array_unique($msgs));
673 23
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
674 23
            $this->stdout("Saving messages to $coloredFileName...\n");
675 23
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
676
        }
677
678 29
        if ($removeUnused) {
679 7
            $this->deleteUnusedPhpMessageFiles(array_keys($messages), $dirName);
680
        }
681 29
    }
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 23
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
696
    {
697 23
        if (is_file($fileName)) {
698 16
            $rawExistingMessages = require $fileName;
699 16
            $existingMessages = $rawExistingMessages;
700 16
            sort($messages);
701 16
            ksort($existingMessages);
702 16
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
703 10
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
704 10
                return ExitCode::OK;
705
            }
706 7
            unset($rawExistingMessages);
707 7
            $merged = [];
708 7
            $untranslated = [];
709 7
            foreach ($messages as $message) {
710 7
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
711 4
                    $merged[$message] = $existingMessages[$message];
712
                } else {
713 7
                    $untranslated[] = $message;
714
                }
715
            }
716 7
            ksort($merged);
717 7
            sort($untranslated);
718 7
            $todo = [];
719 7
            foreach ($untranslated as $message) {
720 6
                $todo[$message] = '';
721
            }
722 7
            ksort($existingMessages);
723 7
            foreach ($existingMessages as $message => $translation) {
724 7
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
725 3
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
726 2
                        $todo[$message] = $translation;
727
                    } else {
728 7
                        $todo[$message] = '@@' . $translation . '@@';
729
                    }
730
                }
731
            }
732 7
            $merged = array_merge($merged, $todo);
733 7
            if ($sort) {
734 1
                ksort($merged);
735
            }
736 7
            if (false === $overwrite) {
737
                $fileName .= '.merged';
738
            }
739 7
            $this->stdout("Translation merged.\n");
740
        } else {
741 11
            $merged = [];
742 11
            foreach ($messages as $message) {
743 11
                $merged[$message] = '';
744
            }
745 11
            ksort($merged);
746
        }
747
748 17
        $array = VarDumper::export($merged);
749
        $content = <<<EOD
750
<?php
751 17
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
752 17
return $array;
753
754
EOD;
755
756 17
        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 17
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
762 17
        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 16
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
777
    {
778 16
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
779 16
        FileHelper::createDirectory(dirname($file));
780 16
        $this->stdout("Saving messages to $file...\n");
781
782 16
        $poFile = new GettextPoFile();
783
784 16
        $merged = [];
785 16
        $todos = [];
786
787 16
        $hasSomethingToWrite = false;
788 16
        foreach ($messages as $category => $msgs) {
789 16
            $notTranslatedYet = [];
790 16
            $msgs = array_values(array_unique($msgs));
791
792 16
            if (is_file($file)) {
793 10
                $existingMessages = $poFile->load($file, $category);
794
795 10
                sort($msgs);
796 10
                ksort($existingMessages);
797 10
                if (array_keys($existingMessages) == $msgs) {
798 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
799
800 4
                    sort($msgs);
801 4
                    foreach ($msgs as $message) {
802 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
803
                    }
804 4
                    ksort($merged);
805 4
                    continue;
806
                }
807
808
                // merge existing message translations with new message translations
809 7
                foreach ($msgs as $message) {
810 7
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
811 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
812
                    } else {
813 7
                        $notTranslatedYet[] = $message;
814
                    }
815
                }
816 7
                ksort($merged);
817 7
                sort($notTranslatedYet);
818
819
                // collect not yet translated messages
820 7
                foreach ($notTranslatedYet as $message) {
821 6
                    $todos[$category . chr(4) . $message] = '';
822
                }
823
824
                // add obsolete unused messages
825 7
                foreach ($existingMessages as $message => $translation) {
826 7
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
827 3
                        if (!$markUnused || (!empty($translation) && (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
828 2
                            $todos[$category . chr(4) . $message] = $translation;
829
                        } else {
830 7
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
831
                        }
832
                    }
833
                }
834
835 7
                $merged = array_merge($merged, $todos);
836 7
                if ($sort) {
837 1
                    ksort($merged);
838
                }
839
840 7
                if ($overwrite === false) {
841 7
                    $file .= '.merged';
842
                }
843
            } else {
844 10
                sort($msgs);
845 10
                foreach ($msgs as $message) {
846 10
                    $merged[$category . chr(4) . $message] = '';
847
                }
848 10
                ksort($merged);
849
            }
850 16
            $this->stdout("Category \"$category\" merged.\n");
851 16
            $hasSomethingToWrite = true;
852
        }
853 16
        if ($hasSomethingToWrite) {
854 16
            $poFile->save($file, $merged);
855 16
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
856
        } else {
857 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
858
        }
859 16
    }
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 7
    private function deleteUnusedPhpMessageFiles($existingCategories, $dirName)
900
    {
901 7
        $messageFiles = FileHelper::findFiles($dirName);
902 7
        foreach ($messageFiles as $messageFile) {
903 7
            $categoryFileName = str_replace($dirName, '', $messageFile);
904 7
            $categoryFileName = ltrim($categoryFileName, DIRECTORY_SEPARATOR);
905 7
            $category = preg_replace('#\.php$#', '', $categoryFileName);
906 7
            $category = str_replace(DIRECTORY_SEPARATOR, '/', $category);
907
908 7
            if (!in_array($category, $existingCategories, true)) {
909 7
                unlink($messageFile);
910
            }
911
        }
912 7
    }
913
914
    /**
915
     * @param string $configFile
916
     * @throws Exception If configuration file does not exists.
917
     * @since 2.0.13
918
     */
919 62
    protected function initConfig($configFile)
920
    {
921 62
        $configFileContent = [];
922 62
        if ($configFile !== null) {
0 ignored issues
show
introduced by
The condition $configFile !== null is always true.
Loading history...
923 62
            $configFile = Yii::getAlias($configFile);
924 62
            if (!is_file($configFile)) {
925 3
                throw new Exception("The configuration file does not exist: $configFile");
926
            }
927 59
            $configFileContent = require $configFile;
928
        }
929
930 59
        $this->config = array_merge(
931 59
            $this->getOptionValues($this->action->id),
932 59
            $configFileContent,
933 59
            $this->getPassedOptionValues()
934
        );
935 59
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
936 59
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
937
938 59
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
939
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
940
        }
941 59
        if (!is_dir($this->config['sourcePath'])) {
942
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
943
        }
944 59
        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 59
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
948 45
            if (!isset($this->config['messagePath'])) {
949
                throw new Exception('The configuration file must specify "messagePath".');
950
            }
951 45
            if (!is_dir($this->config['messagePath'])) {
952
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
953
            }
954
        }
955 59
        if (empty($this->config['languages'])) {
956
            throw new Exception('Languages cannot be empty.');
957
        }
958
959 59
        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 59
    }
981
}
982