Completed
Push — master ( b4adad...8150cd )
by Alexander
78:20 queued 38:22
created

MessageController::deleteUnusedPhpMessageFiles()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 7
cts 7
cp 1
rs 9.9332
c 0
b 0
f 0
cc 3
nc 3
nop 2
crap 3
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 48
    public function options($actionID)
160
    {
161 48
        return array_merge(parent::options($actionID), [
162 48
            '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 3
    public function actionConfig($filePath)
219
    {
220 3
        $filePath = Yii::getAlias($filePath);
221 3
        if (file_exists($filePath)) {
222
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
223
                return ExitCode::OK;
224
            }
225
        }
226
227 3
        $array = VarDumper::export($this->getOptionValues($this->action->id));
228
        $content = <<<EOD
229
<?php
230
/**
231 3
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
232
 *
233 3
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
234
 * It contains parameters for source code messages extraction.
235
 * You may modify this file to suit your needs.
236
 *
237 3
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
238
 * template configuration file with detailed description for each parameter.
239
 */
240 3
return $array;
241
242
EOD;
243
244 3
        if (file_put_contents($filePath, $content, LOCK_EX) === false) {
245
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
246
            return ExitCode::UNSPECIFIED_ERROR;
247
        }
248
249 3
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
250 3
        return ExitCode::OK;
251
    }
252
253
    /**
254
     * Creates a configuration file template for the "extract" command.
255
     *
256
     * The created configuration file contains detailed instructions on
257
     * how to customize it to fit for your needs. After customization,
258
     * you may use this configuration file with the "extract" command.
259
     *
260
     * @param string $filePath output file name or alias.
261
     * @return int CLI exit code
262
     * @throws Exception on failure.
263
     */
264
    public function actionConfigTemplate($filePath)
265
    {
266
        $filePath = Yii::getAlias($filePath);
267
268
        if (file_exists($filePath)) {
269
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
270
                return ExitCode::OK;
271
            }
272
        }
273
274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
275
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
276
            return ExitCode::UNSPECIFIED_ERROR;
277
        }
278
279
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
280
        return ExitCode::OK;
281
    }
282
283
    /**
284
     * Extracts messages to be translated from source code.
285
     *
286
     * This command will search through source code files and extract
287
     * messages that need to be translated in different languages.
288
     *
289
     * @param string $configFile the path or alias of the configuration file.
290
     * You may use the "yii message/config" command to generate
291
     * this file and then customize it for your needs.
292
     * @throws Exception on failure.
293
     */
294 45
    public function actionExtract($configFile = null)
295
    {
296 45
        $this->initConfig($configFile);
297
298 42
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
299
300 42
        $messages = [];
301 42
        foreach ($files as $file) {
302 42
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
303
        }
304
305 42
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
306
307 42
        if (in_array($this->config['format'], ['php', 'po'])) {
308 29
            foreach ($this->config['languages'] as $language) {
309 29
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
310 29
                if (!is_dir($dir) && !@mkdir($dir)) {
311
                    throw new Exception("Directory '{$dir}' can not be created.");
312
                }
313 29
                if ($this->config['format'] === 'po') {
314 14
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
315
                } else {
316 29
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
317
                }
318
            }
319 13
        } elseif ($this->config['format'] === 'db') {
320
            /** @var Connection $db */
321 13
            $db = Instance::ensure($this->config['db'], Connection::className());
0 ignored issues
show
Deprecated Code introduced by
The method yii\base\BaseObject::className() has been deprecated with message: since 2.0.14. On PHP >=5.5, use `::class` instead.

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

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

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