MessageController.php 17.3 KB
Newer Older
Qiang Xue committed
1 2 3
<?php
/**
 * @link http://www.yiiframework.com/
Qiang Xue committed
4
 * @copyright Copyright (c) 2008 Yii Software LLC
Qiang Xue committed
5 6 7
 * @license http://www.yiiframework.com/license/
 */

8 9
namespace yii\console\controllers;

10
use Yii;
11
use yii\console\Controller;
12 13
use yii\console\Exception;
use yii\helpers\FileHelper;
14
use yii\helpers\VarDumper;
15
use yii\i18n\GettextPoFile;
16

Qiang Xue committed
17
/**
18 19
 * Extracts messages to be translated from source files.
 *
Alexander Makarov committed
20 21 22 23 24 25
 * The extracted messages can be saved the following depending on `format`
 * setting in config file:
 *
 * - PHP message source files.
 * - ".po" files.
 * - Database.
Qiang Xue committed
26
 *
27
 * Usage:
28 29
 * 1. Create a configuration file using the 'message/config' command:
 *    yii message/config /path/to/myapp/messages/config.php
30
 * 2. Edit the created config file, adjusting it for your web application needs.
31
 * 3. Run the 'message/extract' command, using created config:
32 33
 *    yii message /path/to/myapp/messages/config.php
 *
Qiang Xue committed
34
 * @author Qiang Xue <qiang.xue@gmail.com>
35
 * @since 2.0
Qiang Xue committed
36
 */
37
class MessageController extends Controller
Qiang Xue committed
38
{
39 40 41 42
    /**
     * @var string controller default action ID.
     */
    public $defaultAction = 'extract';
43

44 45 46 47 48 49 50
    /**
     * Creates a configuration file for the "extract" command.
     *
     * The generated configuration file contains detailed instructions on
     * how to customize it to fit for your needs. After customization,
     * you may use this configuration file with the "extract" command.
     *
51
     * @param string $filePath output file name or alias.
52 53 54 55 56 57 58
     * @throws Exception on failure.
     */
    public function actionConfig($filePath)
    {
        $filePath = Yii::getAlias($filePath);
        if (file_exists($filePath)) {
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
59
                return self::EXIT_CODE_NORMAL;
60 61 62 63 64
            }
        }
        copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath);
        echo "Configuration file template created at '{$filePath}'.\n\n";
    }
65

66 67 68 69 70 71
    /**
     * Extracts messages to be translated from source code.
     *
     * This command will search through source code files and extract
     * messages that need to be translated in different languages.
     *
72 73 74
     * @param string $configFile the path or alias of the configuration file.
     * You may use the "yii message/config" command to generate
     * this file and then customize it for your needs.
75 76 77 78 79 80 81 82
     * @throws Exception on failure.
     */
    public function actionExtract($configFile)
    {
        $configFile = Yii::getAlias($configFile);
        if (!is_file($configFile)) {
            throw new Exception("The configuration file does not exist: $configFile");
        }
83

84 85 86 87 88 89 90
        $config = array_merge([
            'translator' => 'Yii::t',
            'overwrite' => false,
            'removeUnused' => false,
            'sort' => false,
            'format' => 'php',
        ], require($configFile));
91

92 93
        if (!isset($config['sourcePath'], $config['languages'])) {
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
94 95 96 97 98
        }
        if (!is_dir($config['sourcePath'])) {
            throw new Exception("The source path {$config['sourcePath']} is not a valid directory.");
        }
        if (in_array($config['format'], ['php', 'po'])) {
99 100 101
            if (!isset($config['messagePath'])) {
                throw new Exception('The configuration file must specify "messagePath".');
            } elseif (!is_dir($config['messagePath'])) {
102 103 104 105 106 107 108 109 110
                throw new Exception("The message path {$config['messagePath']} is not a valid directory.");
            }
        }
        if (empty($config['languages'])) {
            throw new Exception("Languages cannot be empty.");
        }
        if (empty($config['format']) || !in_array($config['format'], ['php', 'po', 'db'])) {
            throw new Exception('Format should be either "php", "po" or "db".');
        }
Qiang Xue committed
111

112
        $files = FileHelper::findFiles(realpath($config['sourcePath']), $config);
Qiang Xue committed
113

114 115 116 117 118 119 120 121 122 123
        $messages = [];
        foreach ($files as $file) {
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $config['translator']));
        }
        if (in_array($config['format'], ['php', 'po'])) {
            foreach ($config['languages'] as $language) {
                $dir = $config['messagePath'] . DIRECTORY_SEPARATOR . $language;
                if (!is_dir($dir)) {
                    @mkdir($dir);
                }
124 125 126 127 128
                if ($config['format'] === 'po') {
                    $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages';
                    $this->saveMessagesToPO($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $catalog);
                } else {
                    $this->saveMessagesToPHP($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort']);
129 130 131
                }
            }
        } elseif ($config['format'] === 'db') {
132
            $db = \Yii::$app->get(isset($config['db']) ? $config['db'] : 'db');
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
            if (!$db instanceof \yii\db\Connection) {
                throw new Exception('The "db" option must refer to a valid database application component.');
            }
            $sourceMessageTable = isset($config['sourceMessageTable']) ? $config['sourceMessageTable'] : '{{%source_message}}';
            $messageTable = isset($config['messageTable']) ? $config['messageTable'] : '{{%message}}';
            $this->saveMessagesToDb(
                $messages,
                $db,
                $sourceMessageTable,
                $messageTable,
                $config['removeUnused'],
                $config['languages']
            );
        }
    }
Qiang Xue committed
148

149 150 151
    /**
     * Saves messages to database
     *
152
     * @param array $messages
153
     * @param \yii\db\Connection $db
154 155 156 157
     * @param string $sourceMessageTable
     * @param string $messageTable
     * @param boolean $removeUnused
     * @param array $languages
158 159 160 161 162
     */
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages)
    {
        $q = new \yii\db\Query;
        $current = [];
Qiang Xue committed
163

164 165 166
        foreach ($q->select(['id', 'category', 'message'])->from($sourceMessageTable)->all() as $row) {
            $current[$row['category']][$row['id']] = $row['message'];
        }
Qiang Xue committed
167

168 169
        $new = [];
        $obsolete = [];
Qiang Xue committed
170

171 172
        foreach ($messages as $category => $msgs) {
            $msgs = array_unique($msgs);
173

174 175
            if (isset($current[$category])) {
                $new[$category] = array_diff($msgs, $current[$category]);
176
                $obsolete += array_diff($current[$category], $msgs);
177 178 179 180
            } else {
                $new[$category] = $msgs;
            }
        }
181

182 183 184
        foreach (array_diff(array_keys($current), array_keys($messages)) as $category) {
            $obsolete += $current[$category];
        }
185

186 187 188 189 190 191 192
        if (!$removeUnused) {
            foreach ($obsolete as $pk => $m) {
                if (mb_substr($m, 0, 2) === '@@' && mb_substr($m, -2) === '@@') {
                    unset($obsolete[$pk]);
                }
            }
        }
193

194 195 196
        $obsolete = array_keys($obsolete);
        echo "Inserting new messages...";
        $savedFlag = false;
197

198 199 200
        foreach ($new as $category => $msgs) {
            foreach ($msgs as $m) {
                $savedFlag = true;
201

202
                $db->createCommand()
203
                   ->insert($sourceMessageTable, ['category' => $category, 'message' => $m])->execute();
204 205 206
                $lastId = $db->getLastInsertID();
                foreach ($languages as $language) {
                    $db->createCommand()
207
                       ->insert($messageTable, ['id' => $lastId, 'language' => $language])->execute();
208 209 210
                }
            }
        }
211

212 213
        echo $savedFlag ? "saved.\n" : "nothing new...skipped.\n";
        echo $removeUnused ? "Deleting obsoleted messages..." : "Updating obsoleted messages...";
214

215 216 217 218 219
        if (empty($obsolete)) {
            echo "nothing obsoleted...skipped.\n";
        } else {
            if ($removeUnused) {
                $db->createCommand()
220
                   ->delete($sourceMessageTable, ['in', 'id', $obsolete])->execute();
221 222 223 224
                echo "deleted.\n";
            } else {
                $last_id = $db->getLastInsertID();
                $db->createCommand()
225 226 227 228 229
                   ->update(
                       $sourceMessageTable,
                       ['message' => new \yii\db\Expression("CONCAT('@@',message,'@@')")],
                       ['in', 'id', $obsolete]
                   )->execute();
230 231
                foreach ($languages as $language) {
                    $db->createCommand()
232
                       ->insert($messageTable, ['id' => $last_id, 'language' => $language])->execute();
233 234 235 236 237
                }
                echo "updated.\n";
            }
        }
    }
238

239 240 241
    /**
     * Extracts messages from a file
     *
242 243
     * @param string $fileName name of the file to extract messages from
     * @param string $translator name of the function used to translate messages
244 245 246 247 248 249 250 251 252 253 254 255 256
     * @return array
     */
    protected function extractMessages($fileName, $translator)
    {
        echo "Extracting messages from $fileName...\n";
        $subject = file_get_contents($fileName);
        $messages = [];
        if (!is_array($translator)) {
            $translator = [$translator];
        }
        foreach ($translator as $currentTranslator) {
            $n = preg_match_all(
                '/\b' . $currentTranslator . '\s*\(\s*(\'.*?(?<!\\\\)\'|".*?(?<!\\\\)")\s*,\s*(\'.*?(?<!\\\\)\'|".*?(?<!\\\\)")\s*[,\)]/s',
257 258 259 260
                $subject,
                $matches,
                PREG_SET_ORDER
            );
261 262 263 264 265 266 267 268 269 270
            for ($i = 0; $i < $n; ++$i) {
                if (($pos = strpos($matches[$i][1], '.')) !== false) {
                    $category = substr($matches[$i][1], $pos + 1, -1);
                } else {
                    $category = substr($matches[$i][1], 1, -1);
                }
                $message = $matches[$i][2];
                $messages[$category][] = eval("return $message;"); // use eval to eliminate quote escape
            }
        }
271

272 273
        return $messages;
    }
274

275
    /**
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299
     * Writes messages into PHP files
     *
     * @param array $messages
     * @param string $dirName name of the directory to write to
     * @param boolean $overwrite if existing file should be overwritten without backup
     * @param boolean $removeUnused if obsolete translations should be removed
     * @param boolean $sort if translations should be sorted
     */
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort)
    {
        foreach ($messages as $category => $msgs) {
            $file = str_replace("\\", '/', "$dirName/$category.php");
            $path = dirname($file);
            if (!is_dir($path)) {
                mkdir($path, 0755, true);
            }
            $msgs = array_values(array_unique($msgs));
            echo "Saving messages to $file...\n";
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort);
        }
    }

    /**
     * Writes category messages into PHP file
300
     *
301 302 303
     * @param array $messages
     * @param string $fileName name of the file to write to
     * @param boolean $overwrite if existing file should be overwritten without backup
304
     * @param boolean $removeUnused if obsolete translations should be removed
305
     * @param boolean $sort if translations should be sorted
306
     */
307
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort)
308 309
    {
        if (is_file($fileName)) {
310
            $translated = require($fileName);
311 312 313 314
            sort($messages);
            ksort($translated);
            if (array_keys($translated) == $messages) {
                echo "nothing new...skipped.\n";
Digimon committed
315

316
                return self::EXIT_CODE_NORMAL;
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335
            }
            $merged = [];
            $untranslated = [];
            foreach ($messages as $message) {
                if (array_key_exists($message, $translated) && strlen($translated[$message]) > 0) {
                    $merged[$message] = $translated[$message];
                } else {
                    $untranslated[] = $message;
                }
            }
            ksort($merged);
            sort($untranslated);
            $todo = [];
            foreach ($untranslated as $message) {
                $todo[$message] = '';
            }
            ksort($translated);
            foreach ($translated as $message => $translation) {
                if (!isset($merged[$message]) && !isset($todo[$message]) && !$removeUnused) {
336
                    if (substr_compare($translation, '@@', 0, 2) === 0 && substr_compare($translation, '@@', -2) === 0) {
337 338 339 340 341 342 343 344 345 346 347 348 349
                        $todo[$message] = $translation;
                    } else {
                        $todo[$message] = '@@' . $translation . '@@';
                    }
                }
            }
            $merged = array_merge($todo, $merged);
            if ($sort) {
                ksort($merged);
            }
            if (false === $overwrite) {
                $fileName .= '.merged';
            }
350
            echo "Translation merged.\n";
351
        } else {
352 353 354
            $merged = [];
            foreach ($messages as $message) {
                $merged[$message] = '';
355
            }
356
            ksort($merged);
357
        }
358 359 360 361 362
        echo "Saved.\n";


        $array = VarDumper::export($merged);
        $content = <<<EOD
Qiang Xue committed
363 364 365 366
<?php
/**
 * Message translations.
 *
367
 * This file is automatically generated by 'yii {$this->id}' command.
Qiang Xue committed
368 369 370 371 372 373 374 375 376 377 378
 * It contains the localizable messages extracted from source code.
 * You may modify this file by translating the extracted messages.
 *
 * Each array element represents the translation (value) of a message (key).
 * If the value is empty, the message is considered as not translated.
 * Messages that no longer need translation will have their translations
 * enclosed between a pair of '@@' marks.
 *
 * Message string can be used with plural forms format. Check i18n section
 * of the guide for details.
 *
379
 * NOTE: this file must be saved in UTF-8 encoding.
Qiang Xue committed
380 381 382 383
 */
return $array;

EOD;
384

385 386
        file_put_contents($fileName, $content);
    }
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474

    /**
     * Writes messages into PO file
     *
     * @param array $messages
     * @param string $dirName name of the directory to write to
     * @param boolean $overwrite if existing file should be overwritten without backup
     * @param boolean $removeUnused if obsolete translations should be removed
     * @param boolean $sort if translations should be sorted
     * @param string $catalog message catalog
     */
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog)
    {
        $file = str_replace("\\", '/', "$dirName/$catalog.po");
        $path = dirname($file);
        if (!is_dir($path)) {
            mkdir($path, 0755, true);
        }
        echo "Saving messages to $file...\n";

        $poFile = new GettextPoFile();


        $merged = [];
        $notTranslatedYet = [];
        $todos = [];

        foreach ($messages as $category => $msgs) {
            $msgs = array_values(array_unique($msgs));

            if (is_file($file)) {
                $existingMessages = $poFile->load($file, $category);

                sort($msgs);
                ksort($existingMessages);
                if (array_keys($existingMessages) == $msgs) {
                    echo "Nothing new... skipped.\n";
                    return self::EXIT_CODE_NORMAL;
                }

                // merge existing message translations with new message translations
                foreach ($msgs as $message) {
                    if (array_key_exists($message, $existingMessages) && strlen($existingMessages[$message]) > 0) {
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
                    } else {
                        $notTranslatedYet[] = $message;
                    }
                }
                ksort($merged);
                sort($notTranslatedYet);

                // collect not yet translated messages
                foreach ($notTranslatedYet as $message) {
                    $todos[$category . chr(4) . $message] = '';
                }

                // add obsolete unused messages
                foreach ($existingMessages as $message => $translation) {
                    if (!isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message]) && !$removeUnused) {
                        if (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@') {
                            $todos[$category . chr(4) . $message] = $translation;
                        } else {
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
                        }
                    }
                }

                $merged = array_merge($todos, $merged);
                if ($sort) {
                    ksort($merged);
                }

                if ($overwrite === false) {
                    $file .= '.merged';
                }

                echo "Translation merged.\n";
            } else {
                sort($msgs);
                foreach ($msgs as $message) {
                    $merged[$category . chr(4) . $message] = '';
                }
                ksort($merged);
            }
        }
        $poFile->save($file, $merged);
        echo "Saved.\n";
    }
Qiang Xue committed
475
}