diff --git a/src/Symfony/Bridge/Twig/Translation/TwigExtractor.php b/src/Symfony/Bridge/Twig/Translation/TwigExtractor.php new file mode 100644 index 0000000000..6298325c3c --- /dev/null +++ b/src/Symfony/Bridge/Twig/Translation/TwigExtractor.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Translation; + +use Symfony\Component\Finder\Finder; +use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Bridge\Twig\Node\TransNode; + +/** + * TwigExtractor extracts translation messages from a twig template. + * + * @author Michel Salib + */ +class TwigExtractor implements ExtractorInterface +{ + /** + * Default domain for found messages. + * + * @var string + */ + private $defaultDomain = ''; + + /** + * Prefix for found message. + * + * @var string + */ + private $prefix = ''; + + /** + * The twig environment. + * @var \Twig_Environment + */ + private $twig; + + public function __construct(\Twig_Environment $twig) + { + $this->twig = $twig; + } + + /** + * {@inheritDoc} + */ + public function extract($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; + } + + /** + * Extracts trans message from a twig tree. + * + * @param \Twig_Node $node The twig tree root + * @param MessageCatalogue $catalogue The catalogue + */ + private function crawlNode(\Twig_Node $node, MessageCatalogue $catalogue) + { + if ($node instanceof TransNode && !$node->getNode('body') instanceof \Twig_Node_Expression_GetAttr) { + // trans block + $message = $node->getNode('body')->getAttribute('data'); + $domain = $node->getNode('domain')->getAttribute('value'); + $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); + } + } + } + } + + /** + * Extracts a message from a \Twig_Node_Print. + * Return null if not a constant message. + * + * @param \Twig_Node $node + * @return The message (or null) + */ + 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; + } + + /** + * Extracts a domain from a \Twig_Node_Print. + * Return null if no trans filter. + * + * @param \Twig_Node $node + * @return The domain (or null) + */ + 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/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php new file mode 100644 index 0000000000..7bae4ca588 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -0,0 +1,131 @@ + + * + * 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\Bundle\FrameworkBundle\Command\ContainerAwareCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Yaml\Yaml; + +/** + * A command that parse templates to extract translation messages and add them into the translation files. + * + * @author Michel Salib + */ +class TranslationUpdateCommand extends ContainerAwareCommand +{ + /** + * Compiled catalogue of messages. + * @var MessageCatalogue + */ + protected $catalogue; + + /** + * {@inheritDoc} + */ + protected function configure() + { + $this + ->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( + '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' + ) + )) + ->setHelp(<<translation:update command extract translation strings from templates +of a given bundle. 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. + +php app/console translation:update --dump-messages en AcmeBundle +php app/console translation:update --force --prefix="new_" fr AcmeBundle +EOF + ); + } + + /** + * {@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 + $writer = $this->getContainer()->get('translation.writer'); + $supportedFormats = $writer->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'); + $writer->writeTranslations($catalogue, $input->getOption('output-format'), array('path' => $bundleTransPath)); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationDumperPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationDumperPass.php new file mode 100644 index 0000000000..b7ba86018b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationDumperPass.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; + +/** + * Adds tagged translation.formatter services to translation writer + */ +class TranslationDumperPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('translation.writer')) { + return; + } + + $definition = $container->getDefinition('translation.writer'); + + foreach ($container->findTaggedServiceIds('translation.dumper') as $id => $attributes) { + $definition->addMethodCall('addDumper', array($attributes[0]['alias'], new Reference($id))); + } + } +} 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 0000000000..9d5d04450b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslationExtractorPass.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; + +/** + * Adds tagged translation.extractor services to translation extractor + */ +class TranslationExtractorPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + if (!$container->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/TranslatorPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/TranslatorPass.php index 6bd3e43e62..77e8c9470b 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; @@ -26,6 +27,14 @@ class TranslatorPass implements CompilerPassInterface 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 5f34b12ef8..6668186914 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\TranslatorPass; 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\TranslationDumperPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\Scope; @@ -57,6 +59,8 @@ class FrameworkBundle extends Bundle $container->addCompilerPass(new FormPass()); $container->addCompilerPass(new TranslatorPass()); $container->addCompilerPass(new AddCacheWarmerPass()); + $container->addCompilerPass(new TranslationExtractorPass()); + $container->addCompilerPass(new TranslationDumperPass()); 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 07bdbcd0e3..f534b358be 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml @@ -13,11 +13,15 @@ Symfony\Component\Translation\Loader\XliffFileLoader Symfony\Component\Translation\Loader\QtTranslationsLoader Symfony\Component\Translation\Loader\CsvFileLoader - Symfony\Component\Translation\Dumper\PhpDumper - Symfony\Component\Translation\Dumper\XliffDumper - Symfony\Component\Translation\Dumper\YamlDumper - Symfony\Component\Translation\Dumper\QtTranslationsDumper - Symfony\Component\Translation\Dumper\CsvDumper + Symfony\Component\Translation\Dumper\PhpFileDumper + Symfony\Component\Translation\Dumper\XliffFileDumper + Symfony\Component\Translation\Dumper\YamlFileDumper + Symfony\Component\Translation\Dumper\QtFileDumper + Symfony\Component\Translation\Dumper\CsvFileDumper + Symfony\Bundle\FrameworkBundle\Translation\PhpExtractor + Symfony\Bundle\FrameworkBundle\Translation\TranslationLoader + Symfony\Component\Translation\Extractor\ChainExtractor + Symfony\Component\Translation\Writer\TranslationWriter @@ -77,5 +81,15 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/TemplateFinderTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/TemplateFinderTest.php index dc15a17da2..23f2a4fb82 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/TemplateFinderTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/TemplateFinderTest.php @@ -47,7 +47,7 @@ class TemplateFinderTest extends TestCase $finder->findAllTemplates() ); - $this->assertEquals(5, count($templates), '->findAllTemplates() find all templates in the bundles and global folders'); + $this->assertEquals(6, count($templates), '->findAllTemplates() find all templates in the bundles and global folders'); $this->assertContains('BaseBundle::base.format.engine', $templates); $this->assertContains('BaseBundle::this.is.a.template.format.engine', $templates); $this->assertContains('BaseBundle:controller:base.format.engine', $templates); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/views/translation.html.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/views/translation.html.php new file mode 100644 index 0000000000..48ea9fdb08 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/views/translation.html.php @@ -0,0 +1,2 @@ +This template is used for translation message extraction tests +trans('new key') ?> diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/PhpExtractorTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/PhpExtractorTest.php new file mode 100644 index 0000000000..097eb104fe --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/PhpExtractorTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Translation; + +use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use Symfony\Bundle\FrameworkBundle\Translation\PhpExtractor; +use Symfony\Component\Translation\MessageCatalogue; + +class PhpExtractorTest extends TestCase +{ + public function testExtraction() + { + // Arrange + $extractor = new PhpExtractor(); + $extractor->setPrefix('prefix'); + $catalogue = new MessageCatalogue('en'); + + // Act + $extractor->extract(__DIR__.'/../Fixtures/Resources/views/', $catalogue); + + // Assert + $this->assertEquals(1, count($catalogue->all('messages')), '->extract() should find 1 translation'); + $this->assertTrue($catalogue->has('new key'), '->extract() should find at leat "new key" message'); + $this->assertEquals('prefixnew key', $catalogue->get('new key'), '->extract() should apply "prefix" as prefix'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php b/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php new file mode 100644 index 0000000000..dd641d2f8e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Translation/PhpExtractor.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Translation; + +use Symfony\Component\Finder\Finder; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Extractor\ExtractorInterface; + +/** + * PhpExtractor extracts translation messages from a php template. + * + * @author Michel Salib + */ +class PhpExtractor implements ExtractorInterface +{ + const MESSAGE_TOKEN = 300; + const IGNORE_TOKEN = 400; + + /** + * Prefix for new found message. + * + * @var string + */ + private $prefix = ''; + + /** + * The sequence that captures translation messages. + * + * @var array + */ + protected $sequences = array( + array( + '$view', + '[', + '\'translator\'', + ']', + '->', + 'trans', + '(', + self::MESSAGE_TOKEN, + ')', + ), + ); + + /** + * {@inheritDoc} + */ + public function extract($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; + } + + /** + * Normalizes a token. + * + * @param mixed $token + * @return string + */ + protected function normalizeToken($token) + { + if (is_array($token)) { + return $token[1]; + } + + return $token; + } + + /** + * Extracts 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; + } + } + + $message = trim($message, '\''); + + 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 0000000000..df77af5afb --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Translation/TranslationLoader.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Translation; + +use Symfony\Component\Finder\Finder; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Loader\LoaderInterface; + +/** + * TranslationLoader loads translation messages from translation files. + * + * @author Michel Salib + */ +class TranslationLoader +{ + /** + * Loaders used for import. + * + * @var array + */ + private $loaders = array(); + + /** + * Adds a loader to the translation extractor. + * @param string $format The format of the loader + * @param LoaderInterface $loader + */ + public function addLoader($format, LoaderInterface $loader) + { + $this->loaders[$format] = $loader; + } + + /** + * Loads 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 b888e92507..b0e5a0fabf 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml @@ -16,6 +16,7 @@ Symfony\Bridge\Twig\Extension\RoutingExtension Symfony\Bridge\Twig\Extension\YamlExtension Symfony\Bridge\Twig\Extension\FormExtension + Symfony\Bridge\Twig\Translation\TwigExtractor Symfony\Component\HttpKernel\EventListener\ExceptionListener @@ -76,6 +77,11 @@ %twig.form.resources% + + + + + diff --git a/src/Symfony/Component/Translation/Dumper/CsvDumper.php b/src/Symfony/Component/Translation/Dumper/CsvFileDumper.php similarity index 78% rename from src/Symfony/Component/Translation/Dumper/CsvDumper.php rename to src/Symfony/Component/Translation/Dumper/CsvFileDumper.php index 48d9e6a72a..9de0501cd0 100644 --- a/src/Symfony/Component/Translation/Dumper/CsvDumper.php +++ b/src/Symfony/Component/Translation/Dumper/CsvFileDumper.php @@ -14,11 +14,11 @@ namespace Symfony\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; /** - * CsvDumper generates a csv formated string representation of a message catalogue + * CsvFileDumper generates a csv formated string representation of a message catalogue. * * @author Stealth35 */ -class CsvDumper implements DumperInterface +class CsvFileDumper extends FileDumper { private $delimiter = ';'; private $enclosure = '"'; @@ -26,7 +26,7 @@ class CsvDumper implements DumperInterface /** * {@inheritDoc} */ - public function dump(MessageCatalogue $messages, $domain = 'messages') + public function format(MessageCatalogue $messages, $domain = 'messages') { $handle = fopen('php://memory', 'rb+'); @@ -52,4 +52,12 @@ class CsvDumper implements DumperInterface $this->delimiter = $delimiter; $this->enclosure = $enclosure; } + + /** + * {@inheritDoc} + */ + protected function getExtension() + { + return 'csv'; + } } diff --git a/src/Symfony/Component/Translation/Dumper/DumperInterface.php b/src/Symfony/Component/Translation/Dumper/DumperInterface.php index 15828a9d8c..148b0820c1 100644 --- a/src/Symfony/Component/Translation/Dumper/DumperInterface.php +++ b/src/Symfony/Component/Translation/Dumper/DumperInterface.php @@ -15,18 +15,17 @@ use Symfony\Component\Translation\MessageCatalogue; /** * DumperInterface is the interface implemented by all translation dumpers. + * There is no common option. * * @author Michel Salib */ interface DumperInterface { /** - * Generates a string representation of the message catalogue + * Dumps the message catalogue. * * @param MessageCatalogue $messages The message catalogue - * @param string $domain The domain to dump - * - * @return string The string representation + * @param array $options Options that are used by the dumper */ - function dump(MessageCatalogue $messages, $domain = 'messages'); + function dump(MessageCatalogue $messages, $options = array()); } diff --git a/src/Symfony/Component/Translation/Dumper/FileDumper.php b/src/Symfony/Component/Translation/Dumper/FileDumper.php new file mode 100644 index 0000000000..6d25c58093 --- /dev/null +++ b/src/Symfony/Component/Translation/Dumper/FileDumper.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Dumper; + +use Symfony\Component\Translation\MessageCatalogue; + +/** + * FileDumper is an implementation of DumperInterface that dump a message catalogue to file(s). + * Performs backup of already existing files. + * + * Options: + * - path (mandatory): the directory where the files should be saved + * + * @author Michel Salib + */ +abstract class FileDumper implements DumperInterface +{ + /** + * {@inheritDoc} + */ + public function dump(MessageCatalogue $messages, $options = array()) + { + if (!array_key_exists('path', $options)) { + throw new \InvalidArgumentException('The file dumper need a path options.'); + } + + // save a file for each domain + foreach ($messages->getDomains() as $domain) { + $file = $domain.'.'.$messages->getLocale().'.'.$this->getExtension(); + // backup + if (file_exists($options['path'].$file)) { + copy($options['path'].$file, $options['path'].'/'.$file.'~'); + } + // save file + file_put_contents($options['path'].'/'.$file, $this->format($messages, $domain)); + } + } + + /** + * Transforms a domain of a message catalogue to its string representation. + * + * @return The string representation + */ + abstract protected function format(MessageCatalogue $messages, $domain); + + /** + * Gets the file extension of the dumper. + * + * @return The file extension + */ + abstract protected function getExtension(); +} diff --git a/src/Symfony/Component/Translation/Dumper/PhpDumper.php b/src/Symfony/Component/Translation/Dumper/PhpFileDumper.php similarity index 66% rename from src/Symfony/Component/Translation/Dumper/PhpDumper.php rename to src/Symfony/Component/Translation/Dumper/PhpFileDumper.php index 4b226efb81..61801a8827 100644 --- a/src/Symfony/Component/Translation/Dumper/PhpDumper.php +++ b/src/Symfony/Component/Translation/Dumper/PhpFileDumper.php @@ -14,19 +14,27 @@ namespace Symfony\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; /** - * PhpDumper generates a php formated string representation of a message catalogue + * PhpFileDumper generates php files from a message catalogue. * * @author Michel Salib */ -class PhpDumper implements DumperInterface +class PhpFileDumper extends FileDumper { /** * {@inheritDoc} */ - public function dump(MessageCatalogue $messages, $domain = 'messages') + protected function format(MessageCatalogue $messages, $domain) { $output = "all($domain), true).";\n"; return $output; } + + /** + * {@inheritDoc} + */ + protected function getExtension() + { + return 'php'; + } } diff --git a/src/Symfony/Component/Translation/Dumper/QtTranslationsDumper.php b/src/Symfony/Component/Translation/Dumper/QtFileDumper.php similarity index 76% rename from src/Symfony/Component/Translation/Dumper/QtTranslationsDumper.php rename to src/Symfony/Component/Translation/Dumper/QtFileDumper.php index 024cffdd4e..4c1665016b 100644 --- a/src/Symfony/Component/Translation/Dumper/QtTranslationsDumper.php +++ b/src/Symfony/Component/Translation/Dumper/QtFileDumper.php @@ -14,13 +14,16 @@ namespace Symfony\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; /** - * QtTranslationsDumper generates a TS/XML formated string representation of a message catalogue + * QtFileDumper generates ts files from a message catalogue. * * @author Benjamin Eberlei */ -class QtTranslationsDumper implements DumperInterface +class QtFileDumper extends FileDumper { - public function dump(MessageCatalogue $messages, $domain = 'messages') + /** + * {@inheritDoc} + */ + public function format(MessageCatalogue $messages, $domain) { $dom = new \DOMDocument('1.0', 'utf-8'); $dom->formatOutput = true; @@ -36,4 +39,12 @@ class QtTranslationsDumper implements DumperInterface return $dom->saveXML(); } + + /** + * {@inheritDoc} + */ + protected function getExtension() + { + return 'ts'; + } } diff --git a/src/Symfony/Component/Translation/Dumper/XliffDumper.php b/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php similarity index 84% rename from src/Symfony/Component/Translation/Dumper/XliffDumper.php rename to src/Symfony/Component/Translation/Dumper/XliffFileDumper.php index d4242ad913..42bd925cb6 100644 --- a/src/Symfony/Component/Translation/Dumper/XliffDumper.php +++ b/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php @@ -14,16 +14,16 @@ namespace Symfony\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; /** - * XliffDumper generates a xliff formated string representation of a message catalogue + * XliffFileDumper generates xliff files from a message catalogue. * * @author Michel Salib */ -class XliffDumper implements DumperInterface +class XliffFileDumper extends FileDumper { /** * {@inheritDoc} */ - public function dump(MessageCatalogue $messages, $domain = 'messages') + protected function format(MessageCatalogue $messages, $domain) { $dom = new \DOMDocument('1.0', 'utf-8'); $dom->formatOutput = true; @@ -49,4 +49,12 @@ class XliffDumper implements DumperInterface return $dom->saveXML(); } + + /** + * {@inheritDoc} + */ + protected function getExtension() + { + return 'xliff'; + } } diff --git a/src/Symfony/Component/Translation/Dumper/YamlDumper.php b/src/Symfony/Component/Translation/Dumper/YamlFileDumper.php similarity index 65% rename from src/Symfony/Component/Translation/Dumper/YamlDumper.php rename to src/Symfony/Component/Translation/Dumper/YamlFileDumper.php index 4494a8ee97..598be6b308 100644 --- a/src/Symfony/Component/Translation/Dumper/YamlDumper.php +++ b/src/Symfony/Component/Translation/Dumper/YamlFileDumper.php @@ -15,17 +15,25 @@ use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Yaml\Yaml; /** - * YamlDumper generates a yaml formated string representation of a message catalogue + * YamlFileDumper generates yaml files from a message catalogue. * * @author Michel Salib */ -class YamlDumper implements DumperInterface +class YamlFileDumper extends FileDumper { /** * {@inheritDoc} */ - public function dump(MessageCatalogue $messages, $domain = 'messages') + protected function format(MessageCatalogue $messages, $domain) { return Yaml::dump($messages->all($domain)); } + + /** + * {@inheritDoc} + */ + protected function getExtension() + { + return 'yml'; + } } diff --git a/src/Symfony/Component/Translation/Extractor/ChainExtractor.php b/src/Symfony/Component/Translation/Extractor/ChainExtractor.php new file mode 100644 index 0000000000..a7cee09345 --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/ChainExtractor.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Extractor; + +use Symfony\Component\Translation\MessageCatalogue; + +/** + * ChainExtractor extracts translation messages from template files. + * + * @author Michel Salib + */ +class ChainExtractor implements ExtractorInterface +{ + /** + * The extractors. + * + * @var array + */ + private $extractors = array(); + + /** + * Adds a loader to the translation extractor. + * + * @param string $format The format of the loader + * @param ExtractorInterface $extractor The loader + */ + public function addExtractor($format, ExtractorInterface $extractor) + { + $this->extractors[$format] = $extractor; + } + + /** + * {@inheritDoc} + */ + public function setPrefix($prefix) + { + foreach($this->extractors as $extractor){ + $extractor->setPrefix($prefix); + } + } + + /** + * {@inheritDoc} + */ + public function extract($directory, MessageCatalogue $catalogue) + { + foreach ($this->extractors as $extractor) { + $extractor->extract($directory, $catalogue); + } + } +} diff --git a/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php b/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php new file mode 100644 index 0000000000..11dfe8b2bf --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/ExtractorInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Extractor; + +use Symfony\Component\Translation\MessageCatalogue; + +/** + * Extracts translation messages from a template directory to the catalogue. + * New found messages are injected to the catalogue using the prefix. + * + * @author Michel Salib + */ +interface ExtractorInterface +{ + /** + * Extracts translation messages from a template directory to the catalogue. + * + * @param string $directory The path to look into + * @param MessageCatalogue $catalogue The catalogue + */ + function extract($directory, MessageCatalogue $catalogue); + + /** + * Sets the prefix that should be used for new found messages. + * + * @param string $prefix The prefix + */ + public function setPrefix($prefix); +} diff --git a/src/Symfony/Component/Translation/Writer/TranslationWriter.php b/src/Symfony/Component/Translation/Writer/TranslationWriter.php new file mode 100644 index 0000000000..2494106427 --- /dev/null +++ b/src/Symfony/Component/Translation/Writer/TranslationWriter.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Writer; + +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Dumper\DumperInterface; + +/** + * TranslationWriter writes translation messages. + * + * @author Michel Salib + */ +class TranslationWriter +{ + /** + * Dumpers used for export. + * + * @var array + */ + private $dumpers = array(); + + /** + * Adds a dumper to the writer. + * + * @param string $format The format of the dumper + * @param DumperInterface $dumper The dumper + */ + public function addDumper($format, DumperInterface $dumper) + { + $this->dumpers[$format] = $dumper; + } + + /** + * Obtains the list of supported formats. + * + * @return array + */ + public function getFormats() + { + return array_keys($this->dumpers); + } + + /** + * Writes translation from the catalogue according to the selected format. + * + * @param MessageCatalogue $catalogue The message catalogue to dump + * @param type $format The format to use to dump the messages + * @param array $options Options that are passed to the dumper + */ + public function writeTranslations(MessageCatalogue $catalogue, $format, $options = array()) + { + if (!isset($this->dumpers[$format])) { + throw new \InvalidArgumentException('There is no dumper associated with this format.'); + } + + // get the right dumper + $dumper = $this->dumpers[$format]; + + // save + $dumper->dump($catalogue, $options); + } +} diff --git a/tests/Symfony/Tests/Bridge/Twig/Fixtures/Resources/views/translation.html.twig b/tests/Symfony/Tests/Bridge/Twig/Fixtures/Resources/views/translation.html.twig new file mode 100644 index 0000000000..42d477b8bf --- /dev/null +++ b/tests/Symfony/Tests/Bridge/Twig/Fixtures/Resources/views/translation.html.twig @@ -0,0 +1 @@ +stub file that won't be parsed \ No newline at end of file diff --git a/tests/Symfony/Tests/Bridge/Twig/Translation/TwigExtractorTest.php b/tests/Symfony/Tests/Bridge/Twig/Translation/TwigExtractorTest.php new file mode 100644 index 0000000000..7f0fa7dca2 --- /dev/null +++ b/tests/Symfony/Tests/Bridge/Twig/Translation/TwigExtractorTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Tests\Bridge\Twig\Translation; + +use Symfony\Bridge\Twig\Node\TransNode; +use Symfony\Bridge\Twig\Translation\TwigExtractor; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Tests\Bridge\Twig\TestCase; + +class TwigExtractorTest extends TestCase +{ + public function testFilterExtraction() + { + // 1.Arrange + // a node using trans filter : {{ 'new key' | trans({}, 'domain') }} + $transNode = new \Twig_Node_Expression_Filter( + new \Twig_Node_Expression_Constant('first key', 0), + new \Twig_Node_Expression_Constant('trans', 0), + new \Twig_Node(array( + 1 => new \Twig_Node_Expression_Constant('domain', 0) + )), array(), 0); + // a trans block : {% trans from 'domain' %}second key{% endtrans %} + $transBlock = new TransNode( + new \Twig_Node(array(), array('data' => 'second key')), + new \Twig_Node(array(), array('value' => 'domain')) + ); + // mock the twig environment + $twig = $this->getMock('Twig_Environment'); + $twig->expects($this->once()) + ->method('tokenize') + ->will($this->returnValue(new \Twig_TokenStream(array()))) + ; + $twig->expects($this->once()) + ->method('parse') + ->will($this->returnValue( + new \Twig_Node(array( + new \Twig_Node_Text('stub text', 0), + new \Twig_Node_Print($transNode,0), + $transBlock, + )) + )) + ; + // prepare extractor and catalogue + $extractor = new TwigExtractor($twig); + $extractor->setPrefix('prefix'); + $catalogue = new MessageCatalogue('en'); + + // 2.Act + $extractor->extract(__DIR__.'/../Fixtures/Resources/views/', $catalogue); + + // 3.Assert + $this->assertTrue($catalogue->has('first key', 'domain'), '->extract() should find at leat "first key" message in the domain "domain"'); + $this->assertTrue($catalogue->has('second key', 'domain'), '->extract() should find at leat "second key" message in the domain "domain"'); + $this->assertEquals(2, count($catalogue->all('domain')), '->extract() should find 2 translations in the domain "domain"'); + $this->assertEquals('prefixfirst key', $catalogue->get('first key', 'domain'), '->extract() should apply "prefix" as prefix'); + } +} diff --git a/tests/Symfony/Tests/Component/Translation/Dumper/CsvFileDumperTest.php b/tests/Symfony/Tests/Component/Translation/Dumper/CsvFileDumperTest.php index 22bd593aef..b158451e10 100644 --- a/tests/Symfony/Tests/Component/Translation/Dumper/CsvFileDumperTest.php +++ b/tests/Symfony/Tests/Component/Translation/Dumper/CsvFileDumperTest.php @@ -12,7 +12,7 @@ namespace Symfony\Tests\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Dumper\CsvDumper; +use Symfony\Component\Translation\Dumper\CsvFileDumper; class CsvFileDumperTest extends \PHPUnit_Framework_TestCase { @@ -22,9 +22,12 @@ class CsvFileDumperTest extends \PHPUnit_Framework_TestCase $catalogue->add(array('foo' => 'bar', 'bar' => 'foo foo', 'foo;foo' => 'bar')); - $dumper = new CsvDumper(); - $dumperString = $dumper->dump($catalogue); + $tempDir = sys_get_temp_dir(); + $dumper = new CsvFileDumper(); + $dumperString = $dumper->dump($catalogue, array('path' => $tempDir)); - $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/valid.csv'), $dumperString); + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/valid.csv'), file_get_contents($tempDir.'/messages.en.csv')); + + unlink($tempDir.'/messages.en.csv'); } } diff --git a/tests/Symfony/Tests/Component/Translation/Dumper/PhpFileDumperTest.php b/tests/Symfony/Tests/Component/Translation/Dumper/PhpFileDumperTest.php index 0fecdd074d..368259ffb2 100644 --- a/tests/Symfony/Tests/Component/Translation/Dumper/PhpFileDumperTest.php +++ b/tests/Symfony/Tests/Component/Translation/Dumper/PhpFileDumperTest.php @@ -12,7 +12,7 @@ namespace Symfony\Tests\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Dumper\PhpDumper; +use Symfony\Component\Translation\Dumper\PhpFileDumper; class PhpFileDumperTest extends \PHPUnit_Framework_TestCase { @@ -21,9 +21,12 @@ class PhpFileDumperTest extends \PHPUnit_Framework_TestCase $catalogue = new MessageCatalogue('en'); $catalogue->add(array('foo' => 'bar')); - $dumper = new PhpDumper(); - $dumperString = $dumper->dump($catalogue); + $tempDir = sys_get_temp_dir(); + $dumper = new PhpFileDumper(); + $dumperString = $dumper->dump($catalogue, array('path' => $tempDir)); - $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.php'), $dumperString); + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.php'), file_get_contents($tempDir.'/messages.en.php')); + + unlink($tempDir.'/messages.en.php'); } } diff --git a/tests/Symfony/Tests/Component/Translation/Dumper/QtDumperTest.php b/tests/Symfony/Tests/Component/Translation/Dumper/QtFileDumperTest.php similarity index 59% rename from tests/Symfony/Tests/Component/Translation/Dumper/QtDumperTest.php rename to tests/Symfony/Tests/Component/Translation/Dumper/QtFileDumperTest.php index 9966a52c7f..932302f888 100644 --- a/tests/Symfony/Tests/Component/Translation/Dumper/QtDumperTest.php +++ b/tests/Symfony/Tests/Component/Translation/Dumper/QtFileDumperTest.php @@ -12,18 +12,21 @@ namespace Symfony\Tests\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Dumper\QtTranslationsDumper; +use Symfony\Component\Translation\Dumper\QtFileDumper; -class QtTranslationsDumperTest extends \PHPUnit_Framework_TestCase +class QtFileDumperTest extends \PHPUnit_Framework_TestCase { public function testDump() { $catalogue = new MessageCatalogue('en'); $catalogue->add(array('foo' => 'bar'), 'resources'); - $dumper = new QtTranslationsDumper(); - $dumperString = $dumper->dump($catalogue, 'resources'); + $tempDir = sys_get_temp_dir(); + $dumper = new QtFileDumper(); + $dumperString = $dumper->dump($catalogue, array('path' => $tempDir)); - $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.ts'), $dumperString); + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.ts'), file_get_contents($tempDir.'/resources.en.ts')); + + unlink($tempDir.'/resources.en.ts'); } } \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/Translation/Dumper/XliffFileDumperTest.php b/tests/Symfony/Tests/Component/Translation/Dumper/XliffFileDumperTest.php index 5d77910924..9cfedf4099 100644 --- a/tests/Symfony/Tests/Component/Translation/Dumper/XliffFileDumperTest.php +++ b/tests/Symfony/Tests/Component/Translation/Dumper/XliffFileDumperTest.php @@ -12,7 +12,7 @@ namespace Symfony\Tests\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Dumper\XliffDumper; +use Symfony\Component\Translation\Dumper\XliffFileDumper; class XliffFileDumperTest extends \PHPUnit_Framework_TestCase { @@ -21,9 +21,12 @@ class XliffFileDumperTest extends \PHPUnit_Framework_TestCase $catalogue = new MessageCatalogue('en'); $catalogue->add(array('foo' => 'bar')); - $dumper = new XliffDumper(); - $dumperString = $dumper->dump($catalogue); + $tempDir = sys_get_temp_dir(); + $dumper = new XliffFileDumper(); + $dumperString = $dumper->dump($catalogue, array('path' => $tempDir)); - $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.xliff'), $dumperString); + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.xliff'), file_get_contents($tempDir.'/messages.en.xliff')); + + unlink($tempDir.'/messages.en.xliff'); } } diff --git a/tests/Symfony/Tests/Component/Translation/Dumper/YamlFileDumperTest.php b/tests/Symfony/Tests/Component/Translation/Dumper/YamlFileDumperTest.php index 29bb14a8e0..466cd12548 100644 --- a/tests/Symfony/Tests/Component/Translation/Dumper/YamlFileDumperTest.php +++ b/tests/Symfony/Tests/Component/Translation/Dumper/YamlFileDumperTest.php @@ -12,7 +12,7 @@ namespace Symfony\Tests\Component\Translation\Dumper; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Dumper\YamlDumper; +use Symfony\Component\Translation\Dumper\YamlFileDumper; class YamlFileDumperTest extends \PHPUnit_Framework_TestCase { @@ -21,9 +21,12 @@ class YamlFileDumperTest extends \PHPUnit_Framework_TestCase $catalogue = new MessageCatalogue('en'); $catalogue->add(array('foo' => 'bar')); - $dumper = new YamlDumper(); - $dumperString = $dumper->dump($catalogue); + $tempDir = sys_get_temp_dir(); + $dumper = new YamlFileDumper(); + $dumperString = $dumper->dump($catalogue, array('path' => $tempDir)); - $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.yml'), $dumperString); + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/resources.yml'), file_get_contents($tempDir.'/messages.en.yml')); + + unlink($tempDir.'/messages.en.yml'); } }