diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php new file mode 100644 index 0000000000000..a59b6381e62c6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -0,0 +1,111 @@ +setName('translation:update') + ->setDescription('Update the translation file') + ->setDefinition(array( + new InputArgument('locale', InputArgument::REQUIRED, 'The locale'), + new InputArgument('bundle', InputArgument::REQUIRED, 'The bundle where to load the messages'), + new InputOption( + 'prefix', null, InputOption::VALUE_OPTIONAL, + 'Override the default prefix', '__' + ), + new InputOption( + 'output-format', null, InputOption::VALUE_OPTIONAL, + 'Override the default output format', 'yml' + ), + new InputOption( + 'source-lang', null, InputOption::VALUE_OPTIONAL, + 'Set the source language attribute in xliff files', 'en' + ), + 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 update be done' + ) + )); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // check presence of force or dump-message + if ($input->getOption('force') !== true && $input->getOption('dump-messages') !== true) { + $output->writeln('You must choose one of --force or --dump-messages'); + return; + } + + // check format + $fileWriter = $this->getContainer()->get('translation.writer'); + $supportedFormats = $fileWriter->getFormats(); + if (!in_array($input->getOption('output-format'), $supportedFormats)) { + $output->writeln('Wrong output format'); + $output->writeln('Supported formats are '.implode(', ', $supportedFormats).'.'); + return; + } + + // get bundle directory + $foundBundle = $this->getApplication()->getKernel()->getBundle($input->getArgument('bundle')); + $bundleTransPath = $foundBundle->getPath() . '/Resources/translations'; + $output->writeln(sprintf('Generating "%s" translation files for "%s"', $input->getArgument('locale'), $foundBundle->getName())); + + // create catalogue + $catalogue = new MessageCatalogue($input->getArgument('locale')); + + // load any messages from templates + $output->writeln('Parsing templates'); + $extractor = $this->getContainer()->get('translation.extractor'); + $extractor->setPrefix($input->getOption('prefix')); + $extractor->extractMessages($foundBundle->getPath() . '/Resources/views/', $catalogue); + + // load any existing messages from the translation files + $output->writeln('Loading translation files'); + $loader = $this->getContainer()->get('translation.loader'); + $loader->loadMessages($bundleTransPath, $catalogue); + + // show compiled list of messages + if($input->getOption('dump-messages') === true){ + foreach ($catalogue->getDomains() as $domain) { + $output->writeln(sprintf("\nDisplaying messages for domain %s:\n", $domain)); + $output->writeln(Yaml::dump($catalogue->all($domain),10)); + } + if($input->getOption('output-format') == 'xliff') + $output->writeln('Xliff output version is 1.2/info>'); + } + + // save the files + if($input->getOption('force') === true) { + $output->writeln('Writing files'); + $fileWriter->writeTranslations($catalogue, $bundleTransPath, $input->getOption('output-format')); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationExtractorPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationExtractorPass.php new file mode 100644 index 0000000000000..a03cecca7acd5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationExtractorPass.php @@ -0,0 +1,26 @@ +hasDefinition('translation.extractor')) { + return; + } + + $definition = $container->getDefinition('translation.extractor'); + + foreach ($container->findTaggedServiceIds('translation.extractor') as $id => $attributes) { + $definition->addMethodCall('addExtractor', array($attributes[0]['alias'], new Reference($id))); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationWriterPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationWriterPass.php new file mode 100644 index 0000000000000..90a16ba015a06 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationWriterPass.php @@ -0,0 +1,26 @@ +hasDefinition('translation.writer')) { + return; + } + + $definition = $container->getDefinition('translation.writer'); + + foreach ($container->findTaggedServiceIds('translation.formatter') as $id => $attributes) { + $definition->addMethodCall('addFormatter', array($attributes[0]['alias'], new Reference($id))); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslatorPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslatorPass.php index 6bd3e43e62505..edc27d8a9151a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslatorPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslatorPass.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -21,11 +22,19 @@ public function process(ContainerBuilder $container) if (!$container->hasDefinition('translator.default')) { return; } - + $loaders = array(); foreach ($container->findTaggedServiceIds('translation.loader') as $id => $attributes) { $loaders[$id] = $attributes[0]['alias']; } + + if ($container->hasDefinition('translation.loader')) { + $definition = $container->getDefinition('translation.loader'); + foreach ($loaders as $id => $format) { + $definition->addMethodCall('addLoader', array($format, new Reference($id))); + } + } + $container->findDefinition('translator.default')->replaceArgument(2, $loaders); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 5f34b12ef8e40..138269126c9b9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -22,6 +22,8 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddCacheWarmerPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ContainerBuilderDebugDumpPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CompilerDebugDumpPass; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TranslationExtractorPass; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TranslationWriterPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\Scope; @@ -57,6 +59,8 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new FormPass()); $container->addCompilerPass(new TranslatorPass()); $container->addCompilerPass(new AddCacheWarmerPass()); + $container->addCompilerPass(new TranslationExtractorPass()); + $container->addCompilerPass(new TranslationWriterPass()); if ($container->getParameter('kernel.debug')) { $container->addCompilerPass(new ContainerBuilderDebugDumpPass(), PassConfig::TYPE_AFTER_REMOVING); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml index d3b7d74c8c2f5..931d385db3fd1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml @@ -11,6 +11,14 @@ Symfony\Component\Translation\Loader\PhpFileLoader Symfony\Component\Translation\Loader\YamlFileLoader Symfony\Component\Translation\Loader\XliffFileLoader + Symfony\Component\Translation\Formatter\PhpFormatter + Symfony\Component\Translation\Formatter\PotFormatter + Symfony\Component\Translation\Formatter\XliffFormatter + Symfony\Component\Translation\Formatter\YamlFormatter + Symfony\Bundle\FrameworkBundle\Translation\PhpExtractor + Symfony\Bundle\FrameworkBundle\Translation\TranslationLoader + Symfony\Component\Translation\Extractor\TranslationExtractor + Symfony\Component\Translation\Writer\TranslationWriter @@ -42,5 +50,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php b/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php new file mode 100644 index 0000000000000..18736a61ce99c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php @@ -0,0 +1,110 @@ +', + 'trans', + '(', + self::MESSAGE_TOKEN, + ')', + ), + ); + + /** + * {@inheritDoc} + */ + public function load($directory, MessageCatalogue $catalog) + { + // load any existing translation files + $finder = new Finder(); + $files = $finder->files()->name('*.php')->in($directory); + foreach ($files as $file) { + $this->parseTokens(token_get_all(file_get_contents($file)), $catalog); + } + } + + /** + * {@inheritDoc} + */ + public function setPrefix($prefix) + { + $this->prefix = $prefix; + } + + /** + * Normalize a token + * + * @param mixed $token + * @return string + */ + protected function normalizeToken($token) + { + if (is_array($token)) { + return $token[1]; + } + + return $token; + } + + /** + * Extract trans message from php tokens + * + * @param array $tokens + * @param MessageCatalogue $catalog + */ + protected function parseTokens($tokens, MessageCatalogue $catalog) + { + foreach ($tokens as $key => $token) { + foreach ($this->sequences as $sequence) { + $message = ''; + + foreach ($sequence as $id => $item) { + if($this->normalizeToken($tokens[$key + $id]) == $item) { + continue; + } elseif (self::MESSAGE_TOKEN == $item) { + $message = $this->normalizeToken($tokens[$key + $id]); + } elseif (self::IGNORE_TOKEN == $item) { + continue; + } else { + break; + } + } + + if ($message) { + $catalog->set($message, $this->prefix.$message); + break; + } + } + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Translation/TranslationLoader.php b/src/Symfony/Bundle/FrameworkBundle/Translation/TranslationLoader.php new file mode 100644 index 0000000000000..2cc85c6cb04fe --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Translation/TranslationLoader.php @@ -0,0 +1,49 @@ +loaders[$format] = $loader; + } + + /** + * Load translation messages from a directory to the catalogue + * + * @param string $directory the directory to look into + * @param MessageCatalogue $catalogue the catalogue + */ + public function loadMessages($directory, MessageCatalogue $catalogue) + { + foreach($this->loaders as $format => $loader) { + // load any existing translation files + $finder = new Finder(); + $files = $finder->files()->name('*.'.$catalogue->getLocale().$format)->in($directory); + foreach ($files as $file) { + $domain = substr($file->getFileName(), 0, strrpos($file->getFileName(), $input->getArgument('locale').$format) - 1); + $catalogue->addCatalogue($loader->load($file->getPathname(), $input->getArgument('locale'), $domain)); + } + } + } +} diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml index b888e92507e43..bf0f1c682afd4 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml @@ -16,6 +16,9 @@ Symfony\Bridge\Twig\Extension\RoutingExtension Symfony\Bridge\Twig\Extension\YamlExtension Symfony\Bridge\Twig\Extension\FormExtension + Twig_Extensions_Extension_Text + Twig_Extensions_Extension_Debug + Symfony\Bundle\TwigBundle\Translation\TwigExtractor Symfony\Component\HttpKernel\EventListener\ExceptionListener @@ -76,6 +79,11 @@ %twig.form.resources% + + + + + diff --git a/src/Symfony/Bundle/TwigBundle/Translation/TwigExtractor.php b/src/Symfony/Bundle/TwigBundle/Translation/TwigExtractor.php new file mode 100644 index 0000000000000..bf98599007443 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Translation/TwigExtractor.php @@ -0,0 +1,135 @@ +twig = $twig; + } + + /** + * {@inheritDoc} + */ + public function load($directory, MessageCatalogue $catalogue) + { + // load any existing translation files + $finder = new Finder(); + $files = $finder->files()->name('*.twig')->in($directory); + foreach ($files as $file) { + $tree = $this->twig->parse($this->twig->tokenize(file_get_contents($file->getPathname()))); + $this->crawlNode($tree, $catalogue); + } + } + + /** + * {@inheritDoc} + */ + public function setPrefix($prefix) + { + $this->prefix = $prefix; + } + + /** + * Recursive function that extract trans message from a twig tree + * + * @param \Twig_Node $node The twig tree root + * @param MessageCatalogue $catalogue + */ + private function crawlNode(\Twig_Node $node, MessageCatalogue $catalogue) + { + if ($node instanceof TransNode && !$node->getNode('body') instanceof \Twig_Node_Expression_GetAttr) { + // trans block + $domain = $node->getNode('domain')->getAttribute('value'); + $message = $node->getNode('body')->getAttribute('data'); + $catalogue->set($message, $this->prefix.$message, $domain); + } elseif ($node instanceof \Twig_Node_Print) { + // trans filter (be carefull of how you chain your filters) + $message = $this->extractMessage($node->getNode('expr')); + $domain = $this->extractDomain($node->getNode('expr')); + if ($message !== null && $domain !== null) { + $catalogue->set($message, $this->prefix.$message, $domain); + } + } else { + // continue crawling + foreach ($node as $child) { + if ($child != null) { + $this->crawlNode($child, $catalogue); + } + } + } + } + + /** + * Extract a message from a \Twig_Node_Print + * Return null if not a constant message + * + * @param \Twig_Node $node + */ + private function extractMessage(\Twig_Node $node) + { + if ($node->hasNode('node')) { + return $this->extractMessage($node->getNode ('node')); + } + if ($node instanceof \Twig_Node_Expression_Constant) { + return $node->getAttribute('value'); + } + + return null; + } + + /** + * Extract a domain from a \Twig_Node_Print + * Return null if no trans filter + * + * @param \Twig_Node $node + */ + private function extractDomain(\Twig_Node $node) + { + // must be a filter node + if (!$node instanceof \Twig_Node_Expression_Filter) { + return null; + } + // is a trans filter + if($node->getNode('filter')->getAttribute('value') == 'trans') { + if ($node->getNode('arguments')->hasNode(1)) { + return $node->getNode('arguments')->getNode(1)->getAttribute('value'); + } + + return $this->defaultDomain; + } + + return $this->extractDomain($node->getNode('node')); + } +} + diff --git a/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php b/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php new file mode 100644 index 0000000000000..48f7dd2c18278 --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php @@ -0,0 +1,26 @@ +extractors[$format] = $extractor; + } + + /** + * Set prefix applied for new found messages + */ + public function setPrefix($prefix) + { + foreach($this->extractors as $extractor){ + $extractor->setPrefix($prefix); + } + } + + /** + * Extract translation messages from a directory to the catalogue + * + * @param string $directory the directory to look into + * @param MessageCatalogue $catalogue the catalogue + */ + public function extractMessages($directory, MessageCatalogue $catalogue) + { + foreach ($this->extractors as $extractor) { + $extractor->load($directory, $catalogue); + } + } +} diff --git a/src/Symfony/Component/Translation/Formatter/FormatterInterface.php b/src/Symfony/Component/Translation/Formatter/FormatterInterface.php new file mode 100644 index 0000000000000..e935b5d3a0d38 --- /dev/null +++ b/src/Symfony/Component/Translation/Formatter/FormatterInterface.php @@ -0,0 +1,17 @@ +\n"'; + $output[] = '"Language-Team: LANGUAGE \n"'; + $output[] = '"MIME-Version: 1.0\n"'; + $output[] = '"Content-Type: text/plain; charset=UTF-8\n"'; + $output[] = '"Content-Transfer-Encoding: 8bit\n"'; + $output[] = '"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"'; + $output[] = ''; + + foreach ($data as $source => $target) { + $source = $this->clean($source); + $target = $this->clean($target); + + $output[] = "msgid \"{$source}\""; + $output[] = "msgstr \"{$target}\""; + $output[] = ''; + } + + return implode("\n", $output) . "\n"; + } + + /** + * Clean the given message to to make it pot compliant + * @param type $message + * @return string the cleaned message + */ + private function clean($message) + { + $message = strtr($message, array("\\'" => "'", "\\\\" => "\\", "\r\n" => "\n")); + $message = addcslashes($message, "\0..\37\\\""); + + return $message; + } +} diff --git a/src/Symfony/Component/Translation/Formatter/XliffFormatter.php b/src/Symfony/Component/Translation/Formatter/XliffFormatter.php new file mode 100644 index 0000000000000..2bfd8400dcd13 --- /dev/null +++ b/src/Symfony/Component/Translation/Formatter/XliffFormatter.php @@ -0,0 +1,46 @@ + element + */ + public function __construct($source = 'en') + { + $this->source = $source; + } + + /** + * {@inheritDoc} + */ + public function format(array $messages) + { + $dom = new \DOMDocument('1.0', 'utf-8'); + $dom->formatOutput = true; + $xliff = $dom->appendChild($dom->createElement('xliff')); + $xliff->setAttribute('version', '1.2'); + $xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2'); + $xliffFile = $xliff->appendChild($dom->createElement('file')); + $xliffFile->setAttribute('source-language', $this->source); + $xliffFile->setAttribute('datatype', 'plaintext'); + $xliffFile->setAttribute('original', 'file.ext'); + $xliffBody = $xliffFile->appendChild($dom->createElement('body')); + $id = 1; + foreach ($messages as $source => $target) { + $trans = $dom->createElement('trans-unit'); + $trans->setAttribute('id', $id); + $s = $trans->appendChild($dom->createElement('source')); + $s->appendChild($dom->createTextNode($source)); + $t = $trans->appendChild($dom->createElement('target')); + $t->appendChild($dom->createTextNode($target)); + $xliffBody->appendChild($trans); + $id++; + } + + return $dom->saveXML(); + } +} diff --git a/src/Symfony/Component/Translation/Formatter/YamlFormatter.php b/src/Symfony/Component/Translation/Formatter/YamlFormatter.php new file mode 100644 index 0000000000000..5c2960c7b193b --- /dev/null +++ b/src/Symfony/Component/Translation/Formatter/YamlFormatter.php @@ -0,0 +1,16 @@ +formatters[$format] = $formatter; + } + + /** + * Obtains the list of supported formats + * @return array + */ + public function getFormats() + { + return array_keys($this->formatters); + } + + public function writeTranslations(MessageCatalogue $catalogue, $path, $format) + { + // get the right formatter + $formatter = $this->formatters[$format]; + + // save + foreach ($catalogue->getDomains() as $domain) { + $file = $domain.'.'.$catalogue->getLocale().'.'.$format; + if (file_exists($path . $file)) { + copy($path.$file, $path.'~'.$file.'.bak'); + } + file_put_contents($path.'/'.$file, $formatter->format($catalogue->all($domain))); + } + } +}