Skip to content

Commit fb039e0

Browse files
committed
Rename translation:update to translation:extract
1 parent 9ec8912 commit fb039e0

File tree

10 files changed

+389
-282
lines changed

10 files changed

+389
-282
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CHANGELOG
1616
* Add support for resetting container services after each messenger message
1717
* Add `configureContainer()`, `configureRoutes()`, `getConfigDir()` and `getBundlesPath()` to `MicroKernelTrait`
1818
* Add support for configuring log level, and status code by exception class
19+
* Deprecate `translation:update` command, use `translation:extract` instead
1920

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

0 commit comments

Comments
 (0)