PageRenderTime 43ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/framework/console/controllers/MessageController.php

https://github.com/lucianobaraglia/yii2
PHP | 552 lines | 360 code | 54 blank | 138 comment | 75 complexity | f2c33707235e73ce6d70e3edfec8e104 MD5 | raw file
Possible License(s): BSD-3-Clause
  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. namespace yii\console\controllers;
  8. use Yii;
  9. use yii\base\ErrorException;
  10. use yii\console\Controller;
  11. use yii\console\Exception;
  12. use yii\helpers\Console;
  13. use yii\helpers\FileHelper;
  14. use yii\helpers\VarDumper;
  15. use yii\i18n\GettextPoFile;
  16. /**
  17. * Extracts messages to be translated from source files.
  18. *
  19. * The extracted messages can be saved the following depending on `format`
  20. * setting in config file:
  21. *
  22. * - PHP message source files.
  23. * - ".po" files.
  24. * - Database.
  25. *
  26. * Usage:
  27. * 1. Create a configuration file using the 'message/config' command:
  28. * yii message/config /path/to/myapp/messages/config.php
  29. * 2. Edit the created config file, adjusting it for your web application needs.
  30. * 3. Run the 'message/extract' command, using created config:
  31. * yii message /path/to/myapp/messages/config.php
  32. *
  33. * @author Qiang Xue <qiang.xue@gmail.com>
  34. * @since 2.0
  35. */
  36. class MessageController extends Controller
  37. {
  38. /**
  39. * @var string controller default action ID.
  40. */
  41. public $defaultAction = 'extract';
  42. /**
  43. * Creates a configuration file for the "extract" command.
  44. *
  45. * The generated configuration file contains detailed instructions on
  46. * how to customize it to fit for your needs. After customization,
  47. * you may use this configuration file with the "extract" command.
  48. *
  49. * @param string $filePath output file name or alias.
  50. * @return integer CLI exit code
  51. * @throws Exception on failure.
  52. */
  53. public function actionConfig($filePath)
  54. {
  55. $filePath = Yii::getAlias($filePath);
  56. if (file_exists($filePath)) {
  57. if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
  58. return self::EXIT_CODE_NORMAL;
  59. }
  60. }
  61. copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath);
  62. echo "Configuration file template created at '{$filePath}'.\n\n";
  63. }
  64. /**
  65. * Extracts messages to be translated from source code.
  66. *
  67. * This command will search through source code files and extract
  68. * messages that need to be translated in different languages.
  69. *
  70. * @param string $configFile the path or alias of the configuration file.
  71. * You may use the "yii message/config" command to generate
  72. * this file and then customize it for your needs.
  73. * @throws Exception on failure.
  74. */
  75. public function actionExtract($configFile)
  76. {
  77. $configFile = Yii::getAlias($configFile);
  78. if (!is_file($configFile)) {
  79. throw new Exception("The configuration file does not exist: $configFile");
  80. }
  81. $config = array_merge([
  82. 'translator' => 'Yii::t',
  83. 'overwrite' => false,
  84. 'removeUnused' => false,
  85. 'sort' => false,
  86. 'format' => 'php',
  87. ], require($configFile));
  88. if (!isset($config['sourcePath'], $config['languages'])) {
  89. throw new Exception('The configuration file must specify "sourcePath" and "languages".');
  90. }
  91. if (!is_dir($config['sourcePath'])) {
  92. throw new Exception("The source path {$config['sourcePath']} is not a valid directory.");
  93. }
  94. if (empty($config['format']) || !in_array($config['format'], ['php', 'po', 'db'])) {
  95. throw new Exception('Format should be either "php", "po" or "db".');
  96. }
  97. if (in_array($config['format'], ['php', 'po'])) {
  98. if (!isset($config['messagePath'])) {
  99. throw new Exception('The configuration file must specify "messagePath".');
  100. } elseif (!is_dir($config['messagePath'])) {
  101. throw new Exception("The message path {$config['messagePath']} is not a valid directory.");
  102. }
  103. }
  104. if (empty($config['languages'])) {
  105. throw new Exception("Languages cannot be empty.");
  106. }
  107. $files = FileHelper::findFiles(realpath($config['sourcePath']), $config);
  108. $messages = [];
  109. foreach ($files as $file) {
  110. $messages = array_merge_recursive($messages, $this->extractMessages($file, $config['translator']));
  111. }
  112. if (in_array($config['format'], ['php', 'po'])) {
  113. foreach ($config['languages'] as $language) {
  114. $dir = $config['messagePath'] . DIRECTORY_SEPARATOR . $language;
  115. if (!is_dir($dir)) {
  116. @mkdir($dir);
  117. }
  118. if ($config['format'] === 'po') {
  119. $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages';
  120. $this->saveMessagesToPO($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $catalog);
  121. } else {
  122. $this->saveMessagesToPHP($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort']);
  123. }
  124. }
  125. } elseif ($config['format'] === 'db') {
  126. $db = \Yii::$app->get(isset($config['db']) ? $config['db'] : 'db');
  127. if (!$db instanceof \yii\db\Connection) {
  128. throw new Exception('The "db" option must refer to a valid database application component.');
  129. }
  130. $sourceMessageTable = isset($config['sourceMessageTable']) ? $config['sourceMessageTable'] : '{{%source_message}}';
  131. $messageTable = isset($config['messageTable']) ? $config['messageTable'] : '{{%message}}';
  132. $this->saveMessagesToDb(
  133. $messages,
  134. $db,
  135. $sourceMessageTable,
  136. $messageTable,
  137. $config['removeUnused'],
  138. $config['languages']
  139. );
  140. }
  141. }
  142. /**
  143. * Saves messages to database
  144. *
  145. * @param array $messages
  146. * @param \yii\db\Connection $db
  147. * @param string $sourceMessageTable
  148. * @param string $messageTable
  149. * @param boolean $removeUnused
  150. * @param array $languages
  151. */
  152. protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages)
  153. {
  154. $q = new \yii\db\Query;
  155. $current = [];
  156. foreach ($q->select(['id', 'category', 'message'])->from($sourceMessageTable)->all() as $row) {
  157. $current[$row['category']][$row['id']] = $row['message'];
  158. }
  159. $new = [];
  160. $obsolete = [];
  161. foreach ($messages as $category => $msgs) {
  162. $msgs = array_unique($msgs);
  163. if (isset($current[$category])) {
  164. $new[$category] = array_diff($msgs, $current[$category]);
  165. $obsolete += array_diff($current[$category], $msgs);
  166. } else {
  167. $new[$category] = $msgs;
  168. }
  169. }
  170. foreach (array_diff(array_keys($current), array_keys($messages)) as $category) {
  171. $obsolete += $current[$category];
  172. }
  173. if (!$removeUnused) {
  174. foreach ($obsolete as $pk => $m) {
  175. if (mb_substr($m, 0, 2) === '@@' && mb_substr($m, -2) === '@@') {
  176. unset($obsolete[$pk]);
  177. }
  178. }
  179. }
  180. $obsolete = array_keys($obsolete);
  181. echo "Inserting new messages...";
  182. $savedFlag = false;
  183. foreach ($new as $category => $msgs) {
  184. foreach ($msgs as $m) {
  185. $savedFlag = true;
  186. $db->createCommand()
  187. ->insert($sourceMessageTable, ['category' => $category, 'message' => $m])->execute();
  188. $lastID = $db->getLastInsertID();
  189. foreach ($languages as $language) {
  190. $db->createCommand()
  191. ->insert($messageTable, ['id' => $lastID, 'language' => $language])->execute();
  192. }
  193. }
  194. }
  195. echo $savedFlag ? "saved.\n" : "Nothing new...skipped.\n";
  196. echo $removeUnused ? "Deleting obsoleted messages..." : "Updating obsoleted messages...";
  197. if (empty($obsolete)) {
  198. echo "Nothing obsoleted...skipped.\n";
  199. } else {
  200. if ($removeUnused) {
  201. $db->createCommand()
  202. ->delete($sourceMessageTable, ['in', 'id', $obsolete])->execute();
  203. echo "deleted.\n";
  204. } else {
  205. $db->createCommand()
  206. ->update(
  207. $sourceMessageTable,
  208. ['message' => new \yii\db\Expression("CONCAT('@@',message,'@@')")],
  209. ['in', 'id', $obsolete]
  210. )->execute();
  211. echo "updated.\n";
  212. }
  213. }
  214. }
  215. /**
  216. * Extracts messages from a file
  217. *
  218. * @param string $fileName name of the file to extract messages from
  219. * @param string $translator name of the function used to translate messages
  220. * @return array
  221. */
  222. protected function extractMessages($fileName, $translator)
  223. {
  224. $coloredFileName = Console::ansiFormat($fileName, [Console::FG_CYAN]);
  225. echo "Extracting messages from $coloredFileName...\n";
  226. $subject = file_get_contents($fileName);
  227. $messages = [];
  228. if (!is_array($translator)) {
  229. $translator = [$translator];
  230. }
  231. foreach ($translator as $currentTranslator) {
  232. $translatorTokens = token_get_all('<?php ' . $currentTranslator);
  233. array_shift($translatorTokens);
  234. $translatorTokensCount = count($translatorTokens);
  235. $matchedTokensCount = 0;
  236. $buffer = [];
  237. $tokens = token_get_all($subject);
  238. foreach ($tokens as $token) {
  239. // finding out translator call
  240. if ($matchedTokensCount < $translatorTokensCount) {
  241. if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
  242. $matchedTokensCount++;
  243. } else {
  244. $matchedTokensCount = 0;
  245. }
  246. } elseif ($matchedTokensCount === $translatorTokensCount) {
  247. // translator found
  248. // end of translator call or end of something that we can't extract
  249. if ($this->tokensEqual(')', $token)) {
  250. 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) {
  251. // is valid call we can extract
  252. $category = stripcslashes($buffer[0][1]);
  253. $category = mb_substr($category, 1, mb_strlen($category) - 2);
  254. $message = stripcslashes($buffer[2][1]);
  255. $message = mb_substr($message, 1, mb_strlen($message) - 2);
  256. $messages[$category][] = $message;
  257. } else {
  258. // invalid call or dynamic call we can't extract
  259. $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
  260. $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
  261. echo "$skipping $line. Make sure both category and message are static strings.\n";
  262. }
  263. // prepare for the next match
  264. $matchedTokensCount = 0;
  265. $buffer = [];
  266. } elseif ($token !== '(' && isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
  267. // ignore comments, whitespaces and beginning of function call
  268. $buffer[] = $token;
  269. }
  270. }
  271. }
  272. }
  273. echo "\n";
  274. return $messages;
  275. }
  276. /**
  277. * Finds out if two PHP tokens are equal
  278. *
  279. * @param array|string $a
  280. * @param array|string $b
  281. * @return boolean
  282. * @since 2.0.1
  283. */
  284. protected function tokensEqual($a, $b)
  285. {
  286. if (is_string($a) && is_string($b)) {
  287. return $a === $b;
  288. } elseif (isset($a[0], $a[1], $b[0], $b[1])) {
  289. return $a[0] === $b[0] && $a[1] == $b[1];
  290. }
  291. return false;
  292. }
  293. /**
  294. * Finds out a line of the first non-char PHP token found
  295. *
  296. * @param array $tokens
  297. * @return int|string
  298. * @since 2.0.1
  299. */
  300. protected function getLine($tokens)
  301. {
  302. foreach ($tokens as $token) {
  303. if (isset($token[2])) {
  304. return $token[2];
  305. }
  306. }
  307. return 'unknown';
  308. }
  309. /**
  310. * Writes messages into PHP files
  311. *
  312. * @param array $messages
  313. * @param string $dirName name of the directory to write to
  314. * @param boolean $overwrite if existing file should be overwritten without backup
  315. * @param boolean $removeUnused if obsolete translations should be removed
  316. * @param boolean $sort if translations should be sorted
  317. */
  318. protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort)
  319. {
  320. foreach ($messages as $category => $msgs) {
  321. $file = str_replace("\\", '/', "$dirName/$category.php");
  322. $path = dirname($file);
  323. FileHelper::createDirectory($path);
  324. $msgs = array_values(array_unique($msgs));
  325. $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
  326. echo "Saving messages to $coloredFileName...\n";
  327. $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category);
  328. }
  329. }
  330. /**
  331. * Writes category messages into PHP file
  332. *
  333. * @param array $messages
  334. * @param string $fileName name of the file to write to
  335. * @param boolean $overwrite if existing file should be overwritten without backup
  336. * @param boolean $removeUnused if obsolete translations should be removed
  337. * @param boolean $sort if translations should be sorted
  338. * @param string $category message category
  339. */
  340. protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category)
  341. {
  342. if (is_file($fileName)) {
  343. $existingMessages = require($fileName);
  344. sort($messages);
  345. ksort($existingMessages);
  346. if (array_keys($existingMessages) == $messages) {
  347. echo "Nothing new in \"$category\" category... Nothing to save.\n\n";
  348. return;
  349. }
  350. $merged = [];
  351. $untranslated = [];
  352. foreach ($messages as $message) {
  353. if (array_key_exists($message, $existingMessages) && strlen($existingMessages[$message]) > 0) {
  354. $merged[$message] = $existingMessages[$message];
  355. } else {
  356. $untranslated[] = $message;
  357. }
  358. }
  359. ksort($merged);
  360. sort($untranslated);
  361. $todo = [];
  362. foreach ($untranslated as $message) {
  363. $todo[$message] = '';
  364. }
  365. ksort($existingMessages);
  366. foreach ($existingMessages as $message => $translation) {
  367. if (!isset($merged[$message]) && !isset($todo[$message]) && !$removeUnused) {
  368. if (!empty($translation) && strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0) {
  369. $todo[$message] = $translation;
  370. } else {
  371. $todo[$message] = '@@' . $translation . '@@';
  372. }
  373. }
  374. }
  375. $merged = array_merge($todo, $merged);
  376. if ($sort) {
  377. ksort($merged);
  378. }
  379. if (false === $overwrite) {
  380. $fileName .= '.merged';
  381. }
  382. echo "Translation merged.\n";
  383. } else {
  384. $merged = [];
  385. foreach ($messages as $message) {
  386. $merged[$message] = '';
  387. }
  388. ksort($merged);
  389. }
  390. $array = VarDumper::export($merged);
  391. $content = <<<EOD
  392. <?php
  393. /**
  394. * Message translations.
  395. *
  396. * This file is automatically generated by 'yii {$this->id}' command.
  397. * It contains the localizable messages extracted from source code.
  398. * You may modify this file by translating the extracted messages.
  399. *
  400. * Each array element represents the translation (value) of a message (key).
  401. * If the value is empty, the message is considered as not translated.
  402. * Messages that no longer need translation will have their translations
  403. * enclosed between a pair of '@@' marks.
  404. *
  405. * Message string can be used with plural forms format. Check i18n section
  406. * of the guide for details.
  407. *
  408. * NOTE: this file must be saved in UTF-8 encoding.
  409. */
  410. return $array;
  411. EOD;
  412. file_put_contents($fileName, $content);
  413. echo "Saved.\n\n";
  414. }
  415. /**
  416. * Writes messages into PO file
  417. *
  418. * @param array $messages
  419. * @param string $dirName name of the directory to write to
  420. * @param boolean $overwrite if existing file should be overwritten without backup
  421. * @param boolean $removeUnused if obsolete translations should be removed
  422. * @param boolean $sort if translations should be sorted
  423. * @param string $catalog message catalog
  424. */
  425. protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog)
  426. {
  427. $file = str_replace("\\", '/', "$dirName/$catalog.po");
  428. FileHelper::createDirectory(dirname($file));
  429. echo "Saving messages to $file...\n";
  430. $poFile = new GettextPoFile();
  431. $merged = [];
  432. $todos = [];
  433. $hasSomethingToWrite = false;
  434. foreach ($messages as $category => $msgs) {
  435. $notTranslatedYet = [];
  436. $msgs = array_values(array_unique($msgs));
  437. if (is_file($file)) {
  438. $existingMessages = $poFile->load($file, $category);
  439. sort($msgs);
  440. ksort($existingMessages);
  441. if (array_keys($existingMessages) == $msgs) {
  442. echo "Nothing new in \"$category\" category...\n";
  443. sort($msgs);
  444. foreach ($msgs as $message) {
  445. $merged[$category . chr(4) . $message] = $existingMessages[$message];
  446. }
  447. ksort($merged);
  448. continue;
  449. }
  450. // merge existing message translations with new message translations
  451. foreach ($msgs as $message) {
  452. if (array_key_exists($message, $existingMessages) && strlen($existingMessages[$message]) > 0) {
  453. $merged[$category . chr(4) . $message] = $existingMessages[$message];
  454. } else {
  455. $notTranslatedYet[] = $message;
  456. }
  457. }
  458. ksort($merged);
  459. sort($notTranslatedYet);
  460. // collect not yet translated messages
  461. foreach ($notTranslatedYet as $message) {
  462. $todos[$category . chr(4) . $message] = '';
  463. }
  464. // add obsolete unused messages
  465. foreach ($existingMessages as $message => $translation) {
  466. if (!isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message]) && !$removeUnused) {
  467. if (!empty($translation) && substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@') {
  468. $todos[$category . chr(4) . $message] = $translation;
  469. } else {
  470. $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
  471. }
  472. }
  473. }
  474. $merged = array_merge($todos, $merged);
  475. if ($sort) {
  476. ksort($merged);
  477. }
  478. if ($overwrite === false) {
  479. $file .= '.merged';
  480. }
  481. } else {
  482. sort($msgs);
  483. foreach ($msgs as $message) {
  484. $merged[$category . chr(4) . $message] = '';
  485. }
  486. ksort($merged);
  487. }
  488. echo "Category \"$category\" merged.\n";
  489. $hasSomethingToWrite = true;
  490. }
  491. if ($hasSomethingToWrite) {
  492. $poFile->save($file, $merged);
  493. echo "Saved.\n";
  494. } else {
  495. echo "Nothing to save.\n";
  496. }
  497. }
  498. }