* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Bundle\FrameworkBundle\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Translation\Catalogue\MergeOperation; use Symfony\Component\Translation\Catalogue\TargetOperation; use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; /** * A command that parses templates to extract translation messages and adds them * into the translation files. * * @author Michel Salib * * @final */ #[AsCommand(name: 'translation:extract', description: 'Extract missing translations keys from code to translation files')] class TranslationUpdateCommand extends Command { private const ASC = 'asc'; private const DESC = 'desc'; private const SORT_ORDERS = [self::ASC, self::DESC]; private const FORMATS = [ 'xlf12' => ['xlf', '1.2'], 'xlf20' => ['xlf', '2.0'], ]; public function __construct( private TranslationWriterInterface $writer, private TranslationReaderInterface $reader, private ExtractorInterface $extractor, private string $defaultLocale, private ?string $defaultTransPath = null, private ?string $defaultViewsPath = null, private array $transPaths = [], private array $codePaths = [], private array $enabledLocales = [], ) { parent::__construct(); } protected function configure(): void { $this ->setDefinition([ new InputArgument('locale', InputArgument::REQUIRED, 'The locale'), new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'), new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'), new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'), new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'), new InputOption('force', null, InputOption::VALUE_NONE, 'Should the extract be done'), new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'), new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to extract'), new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically (only works with --dump-messages)', 'asc'), 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'), ]) ->setHelp(<<<'EOF' The %command.name% command extracts translation strings from templates of a given bundle or the default translations directory. It can display them or merge the new ones into the translation files. When new translation strings are found it can automatically add a prefix to the translation message. Example running against a Bundle (AcmeBundle) php %command.full_name% --dump-messages en AcmeBundle php %command.full_name% --force --prefix="new_" fr AcmeBundle Example running against default messages directory php %command.full_name% --dump-messages en php %command.full_name% --force --prefix="new_" fr You can sort the output with the --sort flag: php %command.full_name% --dump-messages --sort=asc en AcmeBundle php %command.full_name% --dump-messages --sort=desc fr You can dump a tree-like structure using the yaml format with --as-tree flag: php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle EOF ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $errorIo = $output instanceof ConsoleOutputInterface ? new SymfonyStyle($input, $output->getErrorOutput()) : $io; $io = new SymfonyStyle($input, $output); $errorIo = $io->getErrorStyle(); // check presence of force or dump-message if (true !== $input->getOption('force') && true !== $input->getOption('dump-messages')) { $errorIo->error('You must choose one of --force or --dump-messages'); return 1; } $format = $input->getOption('format'); $xliffVersion = '1.2'; if (\array_key_exists($format, self::FORMATS)) { [$format, $xliffVersion] = self::FORMATS[$format]; } // check format $supportedFormats = $this->writer->getFormats(); if (!\in_array($format, $supportedFormats, true)) { $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']); return 1; } /** @var KernelInterface $kernel */ $kernel = $this->getApplication()->getKernel(); // Define Root Paths $transPaths = $this->getRootTransPaths(); $codePaths = $this->getRootCodePaths($kernel); $currentName = 'default directory'; // Override with provided Bundle info if (null !== $input->getArgument('bundle')) { try { $foundBundle = $kernel->getBundle($input->getArgument('bundle')); $bundleDir = $foundBundle->getPath(); $transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations']; $codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates']; if ($this->defaultTransPath) { $transPaths[] = $this->defaultTransPath; } if ($this->defaultViewsPath) { $codePaths[] = $this->defaultViewsPath; } $currentName = $foundBundle->getName(); } catch (\InvalidArgumentException) { // such a bundle does not exist, so treat the argument as path $path = $input->getArgument('bundle'); $transPaths = [$path.'/translations']; $codePaths = [$path.'/templates']; if (!is_dir($transPaths[0])) { throw new InvalidArgumentException(sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0])); } } } $io->title('Translation Messages Extractor and Dumper'); $io->comment(sprintf('Generating "%s" translation files for "%s"', $input->getArgument('locale'), $currentName)); $io->comment('Parsing templates...'); $extractedCatalogue = $this->extractMessages($input->getArgument('locale'), $codePaths, $input->getOption('prefix')); $io->comment('Loading translation files...'); $currentCatalogue = $this->loadCurrentMessages($input->getArgument('locale'), $transPaths); if (null !== $domain = $input->getOption('domain')) { $currentCatalogue = $this->filterCatalogue($currentCatalogue, $domain); $extractedCatalogue = $this->filterCatalogue($extractedCatalogue, $domain); } // process catalogues $operation = $input->getOption('clean') ? new TargetOperation($currentCatalogue, $extractedCatalogue) : new MergeOperation($currentCatalogue, $extractedCatalogue); // Exit if no messages found. if (!\count($operation->getDomains())) { $errorIo->warning('No translation messages were found.'); return 0; } $resultMessage = 'Translation files were successfully updated'; $operation->moveMessagesToIntlDomainsIfPossible('new'); // show compiled list of messages if (true === $input->getOption('dump-messages')) { $extractedMessagesCount = 0; $io->newLine(); foreach ($operation->getDomains() as $domain) { $newKeys = array_keys($operation->getNewMessages($domain)); $allKeys = array_keys($operation->getMessages($domain)); $list = array_merge( array_diff($allKeys, $newKeys), array_map(fn ($id) => sprintf('%s', $id), $newKeys), array_map(fn ($id) => sprintf('%s', $id), array_keys($operation->getObsoleteMessages($domain))) ); $domainMessagesCount = \count($list); if ($sort = $input->getOption('sort')) { $sort = strtolower($sort); if (!\in_array($sort, self::SORT_ORDERS, true)) { $errorIo->error(['Wrong sort order', 'Supported formats are: '.implode(', ', self::SORT_ORDERS).'.']); return 1; } if (self::DESC === $sort) { rsort($list); } else { sort($list); } } $io->section(sprintf('Messages extracted for domain "%s" (%d message%s)', $domain, $domainMessagesCount, $domainMessagesCount > 1 ? 's' : '')); $io->listing($list); $extractedMessagesCount += $domainMessagesCount; } if ('xlf' === $format) { $io->comment(sprintf('Xliff output version is %s', $xliffVersion)); } $resultMessage = sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was'); } // save the files if (true === $input->getOption('force')) { $io->comment('Writing files...'); $bundleTransPath = false; foreach ($transPaths as $path) { if (is_dir($path)) { $bundleTransPath = $path; } } if (!$bundleTransPath) { $bundleTransPath = end($transPaths); } $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]); if (true === $input->getOption('dump-messages')) { $resultMessage .= ' and translation files were updated'; } } $io->success($resultMessage.'.'); return 0; } public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestArgumentValuesFor('locale')) { $suggestions->suggestValues($this->enabledLocales); return; } /** @var KernelInterface $kernel */ $kernel = $this->getApplication()->getKernel(); if ($input->mustSuggestArgumentValuesFor('bundle')) { $bundles = []; foreach ($kernel->getBundles() as $bundle) { $bundles[] = $bundle->getName(); if ($bundle->getContainerExtension()) { $bundles[] = $bundle->getContainerExtension()->getAlias(); } } $suggestions->suggestValues($bundles); return; } if ($input->mustSuggestOptionValuesFor('format')) { $suggestions->suggestValues(array_merge( $this->writer->getFormats(), array_keys(self::FORMATS) )); return; } if ($input->mustSuggestOptionValuesFor('domain') && $locale = $input->getArgument('locale')) { $extractedCatalogue = $this->extractMessages($locale, $this->getRootCodePaths($kernel), $input->getOption('prefix')); $currentCatalogue = $this->loadCurrentMessages($locale, $this->getRootTransPaths()); // process catalogues $operation = $input->getOption('clean') ? new TargetOperation($currentCatalogue, $extractedCatalogue) : new MergeOperation($currentCatalogue, $extractedCatalogue); $suggestions->suggestValues($operation->getDomains()); return; } if ($input->mustSuggestOptionValuesFor('sort')) { $suggestions->suggestValues(self::SORT_ORDERS); } } private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue { $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); // extract intl-icu messages only $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; if ($intlMessages = $catalogue->all($intlDomain)) { $filteredCatalogue->add($intlMessages, $intlDomain); } // extract all messages and subtract intl-icu messages if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { $filteredCatalogue->add($messages, $domain); } foreach ($catalogue->getResources() as $resource) { $filteredCatalogue->addResource($resource); } if ($metadata = $catalogue->getMetadata('', $intlDomain)) { foreach ($metadata as $k => $v) { $filteredCatalogue->setMetadata($k, $v, $intlDomain); } } if ($metadata = $catalogue->getMetadata('', $domain)) { foreach ($metadata as $k => $v) { $filteredCatalogue->setMetadata($k, $v, $domain); } } return $filteredCatalogue; } private function extractMessages(string $locale, array $transPaths, string $prefix): MessageCatalogue { $extractedCatalogue = new MessageCatalogue($locale); $this->extractor->setPrefix($prefix); $transPaths = $this->filterDuplicateTransPaths($transPaths); foreach ($transPaths as $path) { if (is_dir($path) || is_file($path)) { $this->extractor->extract($path, $extractedCatalogue); } } return $extractedCatalogue; } private function filterDuplicateTransPaths(array $transPaths): array { $transPaths = array_filter(array_map('realpath', $transPaths)); sort($transPaths); $filteredPaths = []; foreach ($transPaths as $path) { foreach ($filteredPaths as $filteredPath) { if (str_starts_with($path, $filteredPath.\DIRECTORY_SEPARATOR)) { continue 2; } } $filteredPaths[] = $path; } return $filteredPaths; } private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue { $currentCatalogue = new MessageCatalogue($locale); foreach ($transPaths as $path) { if (is_dir($path)) { $this->reader->read($path, $currentCatalogue); } } return $currentCatalogue; } private function getRootTransPaths(): array { $transPaths = $this->transPaths; if ($this->defaultTransPath) { $transPaths[] = $this->defaultTransPath; } return $transPaths; } private function getRootCodePaths(KernelInterface $kernel): array { $codePaths = $this->codePaths; $codePaths[] = $kernel->getProjectDir().'/src'; if ($this->defaultViewsPath) { $codePaths[] = $this->defaultViewsPath; } return $codePaths; } }