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)));
+ }
+ }
+}