TranslationUpdateCommand.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Bundle\FrameworkBundle\Command;
  11. use Symfony\Component\Console\Command\Command;
  12. use Symfony\Component\Console\Exception\InvalidArgumentException;
  13. use Symfony\Component\Console\Input\InputArgument;
  14. use Symfony\Component\Console\Input\InputInterface;
  15. use Symfony\Component\Console\Input\InputOption;
  16. use Symfony\Component\Console\Output\OutputInterface;
  17. use Symfony\Component\Console\Style\SymfonyStyle;
  18. use Symfony\Component\HttpKernel\KernelInterface;
  19. use Symfony\Component\Translation\Catalogue\MergeOperation;
  20. use Symfony\Component\Translation\Catalogue\TargetOperation;
  21. use Symfony\Component\Translation\Extractor\ExtractorInterface;
  22. use Symfony\Component\Translation\MessageCatalogue;
  23. use Symfony\Component\Translation\MessageCatalogueInterface;
  24. use Symfony\Component\Translation\Reader\TranslationReaderInterface;
  25. use Symfony\Component\Translation\Writer\TranslationWriterInterface;
  26. /**
  27. * A command that parses templates to extract translation messages and adds them
  28. * into the translation files.
  29. *
  30. * @author Michel Salib <michelsalib@hotmail.com>
  31. *
  32. * @final
  33. */
  34. class TranslationUpdateCommand extends Command
  35. {
  36. private const ASC = 'asc';
  37. private const DESC = 'desc';
  38. private const SORT_ORDERS = [self::ASC, self::DESC];
  39. protected static $defaultName = 'translation:update';
  40. private $writer;
  41. private $reader;
  42. private $extractor;
  43. private $defaultLocale;
  44. private $defaultTransPath;
  45. private $defaultViewsPath;
  46. private $transPaths;
  47. private $viewsPaths;
  48. public function __construct(TranslationWriterInterface $writer, TranslationReaderInterface $reader, ExtractorInterface $extractor, string $defaultLocale, string $defaultTransPath = null, string $defaultViewsPath = null, array $transPaths = [], array $viewsPaths = [])
  49. {
  50. parent::__construct();
  51. $this->writer = $writer;
  52. $this->reader = $reader;
  53. $this->extractor = $extractor;
  54. $this->defaultLocale = $defaultLocale;
  55. $this->defaultTransPath = $defaultTransPath;
  56. $this->defaultViewsPath = $defaultViewsPath;
  57. $this->transPaths = $transPaths;
  58. $this->viewsPaths = $viewsPaths;
  59. }
  60. /**
  61. * {@inheritdoc}
  62. */
  63. protected function configure()
  64. {
  65. $this
  66. ->setDefinition([
  67. new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
  68. new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'),
  69. new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'),
  70. new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'),
  71. new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'),
  72. new InputOption('force', null, InputOption::VALUE_NONE, 'Should the update be done'),
  73. new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'),
  74. new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to update'),
  75. new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'),
  76. new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically', 'asc'),
  77. new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'),
  78. ])
  79. ->setDescription('Update the translation file')
  80. ->setHelp(<<<'EOF'
  81. The <info>%command.name%</info> command extracts translation strings from templates
  82. of a given bundle or the default translations directory. It can display them or merge
  83. the new ones into the translation files.
  84. When new translation strings are found it can automatically add a prefix to the translation
  85. message.
  86. Example running against a Bundle (AcmeBundle)
  87. <info>php %command.full_name% --dump-messages en AcmeBundle</info>
  88. <info>php %command.full_name% --force --prefix="new_" fr AcmeBundle</info>
  89. Example running against default messages directory
  90. <info>php %command.full_name% --dump-messages en</info>
  91. <info>php %command.full_name% --force --prefix="new_" fr</info>
  92. You can sort the output with the <comment>--sort</> flag:
  93. <info>php %command.full_name% --dump-messages --sort=asc en AcmeBundle</info>
  94. <info>php %command.full_name% --dump-messages --sort=desc fr</info>
  95. You can dump a tree-like structure using the yaml format with <comment>--as-tree</> flag:
  96. <info>php %command.full_name% --force --output-format=yaml --as-tree=3 en AcmeBundle</info>
  97. <info>php %command.full_name% --force --output-format=yaml --sort=asc --as-tree=3 fr</info>
  98. EOF
  99. )
  100. ;
  101. }
  102. /**
  103. * {@inheritdoc}
  104. */
  105. protected function execute(InputInterface $input, OutputInterface $output): int
  106. {
  107. $io = new SymfonyStyle($input, $output);
  108. $errorIo = $io->getErrorStyle();
  109. // check presence of force or dump-message
  110. if (true !== $input->getOption('force') && true !== $input->getOption('dump-messages')) {
  111. $errorIo->error('You must choose one of --force or --dump-messages');
  112. return 1;
  113. }
  114. // check format
  115. $supportedFormats = $this->writer->getFormats();
  116. if (!\in_array($input->getOption('output-format'), $supportedFormats, true)) {
  117. $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).'.']);
  118. return 1;
  119. }
  120. /** @var KernelInterface $kernel */
  121. $kernel = $this->getApplication()->getKernel();
  122. // Define Root Paths
  123. $transPaths = $this->transPaths;
  124. if ($this->defaultTransPath) {
  125. $transPaths[] = $this->defaultTransPath;
  126. }
  127. $viewsPaths = $this->viewsPaths;
  128. if ($this->defaultViewsPath) {
  129. $viewsPaths[] = $this->defaultViewsPath;
  130. }
  131. $currentName = 'default directory';
  132. // Override with provided Bundle info
  133. if (null !== $input->getArgument('bundle')) {
  134. try {
  135. $foundBundle = $kernel->getBundle($input->getArgument('bundle'));
  136. $bundleDir = $foundBundle->getPath();
  137. $transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations'];
  138. $viewsPaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates'];
  139. if ($this->defaultTransPath) {
  140. $transPaths[] = $this->defaultTransPath;
  141. }
  142. if ($this->defaultViewsPath) {
  143. $viewsPaths[] = $this->defaultViewsPath;
  144. }
  145. $currentName = $foundBundle->getName();
  146. } catch (\InvalidArgumentException $e) {
  147. // such a bundle does not exist, so treat the argument as path
  148. $path = $input->getArgument('bundle');
  149. $transPaths = [$path.'/translations'];
  150. $viewsPaths = [$path.'/templates'];
  151. if (!is_dir($transPaths[0]) && !isset($transPaths[1])) {
  152. throw new InvalidArgumentException(sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0]));
  153. }
  154. }
  155. }
  156. $io->title('Translation Messages Extractor and Dumper');
  157. $io->comment(sprintf('Generating "<info>%s</info>" translation files for "<info>%s</info>"', $input->getArgument('locale'), $currentName));
  158. // load any messages from templates
  159. $extractedCatalogue = new MessageCatalogue($input->getArgument('locale'));
  160. $io->comment('Parsing templates...');
  161. $this->extractor->setPrefix($input->getOption('prefix'));
  162. foreach ($viewsPaths as $path) {
  163. if (is_dir($path) || is_file($path)) {
  164. $this->extractor->extract($path, $extractedCatalogue);
  165. }
  166. }
  167. // load any existing messages from the translation files
  168. $currentCatalogue = new MessageCatalogue($input->getArgument('locale'));
  169. $io->comment('Loading translation files...');
  170. foreach ($transPaths as $path) {
  171. if (is_dir($path)) {
  172. $this->reader->read($path, $currentCatalogue);
  173. }
  174. }
  175. if (null !== $domain = $input->getOption('domain')) {
  176. $currentCatalogue = $this->filterCatalogue($currentCatalogue, $domain);
  177. $extractedCatalogue = $this->filterCatalogue($extractedCatalogue, $domain);
  178. }
  179. // process catalogues
  180. $operation = $input->getOption('clean')
  181. ? new TargetOperation($currentCatalogue, $extractedCatalogue)
  182. : new MergeOperation($currentCatalogue, $extractedCatalogue);
  183. // Exit if no messages found.
  184. if (!\count($operation->getDomains())) {
  185. $errorIo->warning('No translation messages were found.');
  186. return 0;
  187. }
  188. $resultMessage = 'Translation files were successfully updated';
  189. // move new messages to intl domain when possible
  190. if (class_exists(\MessageFormatter::class)) {
  191. foreach ($operation->getDomains() as $domain) {
  192. $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
  193. $newMessages = $operation->getNewMessages($domain);
  194. if ([] === $newMessages || ([] === $currentCatalogue->all($intlDomain) && [] !== $currentCatalogue->all($domain))) {
  195. continue;
  196. }
  197. $result = $operation->getResult();
  198. $allIntlMessages = $result->all($intlDomain);
  199. $currentMessages = array_diff_key($newMessages, $result->all($domain));
  200. $result->replace($currentMessages, $domain);
  201. $result->replace($allIntlMessages + $newMessages, $intlDomain);
  202. }
  203. }
  204. // show compiled list of messages
  205. if (true === $input->getOption('dump-messages')) {
  206. $extractedMessagesCount = 0;
  207. $io->newLine();
  208. foreach ($operation->getDomains() as $domain) {
  209. $newKeys = array_keys($operation->getNewMessages($domain));
  210. $allKeys = array_keys($operation->getMessages($domain));
  211. $list = array_merge(
  212. array_diff($allKeys, $newKeys),
  213. array_map(function ($id) {
  214. return sprintf('<fg=green>%s</>', $id);
  215. }, $newKeys),
  216. array_map(function ($id) {
  217. return sprintf('<fg=red>%s</>', $id);
  218. }, array_keys($operation->getObsoleteMessages($domain)))
  219. );
  220. $domainMessagesCount = \count($list);
  221. if ($sort = $input->getOption('sort')) {
  222. $sort = strtolower($sort);
  223. if (!\in_array($sort, self::SORT_ORDERS, true)) {
  224. $errorIo->error(['Wrong sort order', 'Supported formats are: '.implode(', ', self::SORT_ORDERS).'.']);
  225. return 1;
  226. }
  227. if (self::DESC === $sort) {
  228. rsort($list);
  229. } else {
  230. sort($list);
  231. }
  232. }
  233. $io->section(sprintf('Messages extracted for domain "<info>%s</info>" (%d message%s)', $domain, $domainMessagesCount, $domainMessagesCount > 1 ? 's' : ''));
  234. $io->listing($list);
  235. $extractedMessagesCount += $domainMessagesCount;
  236. }
  237. if ('xlf' === $input->getOption('output-format')) {
  238. $io->comment(sprintf('Xliff output version is <info>%s</info>', $input->getOption('xliff-version')));
  239. }
  240. $resultMessage = sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was');
  241. }
  242. // save the files
  243. if (true === $input->getOption('force')) {
  244. $io->comment('Writing files...');
  245. $bundleTransPath = false;
  246. foreach ($transPaths as $path) {
  247. if (is_dir($path)) {
  248. $bundleTransPath = $path;
  249. }
  250. }
  251. if (!$bundleTransPath) {
  252. $bundleTransPath = end($transPaths);
  253. }
  254. $this->writer->write($operation->getResult(), $input->getOption('output-format'), ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $input->getOption('xliff-version'), 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]);
  255. if (true === $input->getOption('dump-messages')) {
  256. $resultMessage .= ' and translation files were updated';
  257. }
  258. }
  259. $io->success($resultMessage.'.');
  260. return 0;
  261. }
  262. private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue
  263. {
  264. $filteredCatalogue = new MessageCatalogue($catalogue->getLocale());
  265. // extract intl-icu messages only
  266. $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
  267. if ($intlMessages = $catalogue->all($intlDomain)) {
  268. $filteredCatalogue->add($intlMessages, $intlDomain);
  269. }
  270. // extract all messages and subtract intl-icu messages
  271. if ($messages = array_diff($catalogue->all($domain), $intlMessages)) {
  272. $filteredCatalogue->add($messages, $domain);
  273. }
  274. foreach ($catalogue->getResources() as $resource) {
  275. $filteredCatalogue->addResource($resource);
  276. }
  277. if ($metadata = $catalogue->getMetadata('', $intlDomain)) {
  278. foreach ($metadata as $k => $v) {
  279. $filteredCatalogue->setMetadata($k, $v, $intlDomain);
  280. }
  281. }
  282. if ($metadata = $catalogue->getMetadata('', $domain)) {
  283. foreach ($metadata as $k => $v) {
  284. $filteredCatalogue->setMetadata($k, $v, $domain);
  285. }
  286. }
  287. return $filteredCatalogue;
  288. }
  289. }