merged branch michelsalib/translation-command (PR #2051)

Commits
-------

ef322f6 -- add command that extracts translation messages from templates

Discussion
----------

[2.1] Extracting translation messages from templates

As seen here #1283 and here #2045, I push the command that extract translation from templates.

There are still a lot of new things here, but it seems more manageable.

---------------------------------------------------------------------------

by stof at 2011/09/04 02:04:40 -0700

@michelsalib Could you try to refactor the code to make it more flexible by moving the creating of the file to the dumpers to support other outputs (database...) ?

---------------------------------------------------------------------------

by michelsalib at 2011/09/04 02:35:50 -0700

You are right, I shall do it tonight.

---------------------------------------------------------------------------

by michelsalib at 2011/09/04 11:53:35 -0700

I just pushed a refactoring that should allow more flexibility. Dumpers are now responsive for writing the files. This way it is now possible to implement the DumperInterface that dump to a database and add it to the TranslationWriter.
I updated the tests accordingly.

---------------------------------------------------------------------------

by fabpot at 2011/09/05 23:27:27 -0700

To be consistent with other dumpers in the framework, the dumpers extending `FileDumper` should use `FileDumper` as a suffix like in `YmlFileDumper`.

---------------------------------------------------------------------------

by fabpot at 2011/09/05 23:41:12 -0700

A general note on PHPDoc: The first line of a phpdoc ends with a dot and starts with a present verb like in  `Extracts translation messages from template files.`

---------------------------------------------------------------------------

by michelsalib at 2011/09/06 01:23:31 -0700

I fixed most of the remarks. I just need to go through the phpdoc (in a few minutes).

---------------------------------------------------------------------------

by stloyd at 2011/09/06 01:28:55 -0700

@michelsalib you should use `git rebase` (see [docs](http://symfony.com/doc/current/contributing/code/patches.html#id1)) instead of `git merge`.

---------------------------------------------------------------------------

by michelsalib at 2011/09/06 01:31:06 -0700

@stloyd sorry. I will rebase (squash) everything when I am finished.

---------------------------------------------------------------------------

by kaiwa at 2011/09/06 01:31:18 -0700

Hey, it might be a little bit late, but may i ask a not code-related question?

Is it correct that the `--force` option only means to write the output to a file instead of writing it to stdout?

If so,

1. is it semantically correct? I mean... i'm not a native english speaker, but from unix programs i'm used to interpret a `--force` option as something like "overwrite", "ignore errors" or "suppress warnings". An option which is used in case of trouble most time. Feels confusing to me to be forced ;-) to use the `--force` option for simply writing to files.

2. does it makes sense to have a default behaviour instead of requiring the user to give either `--force` or `--dump-messages`? In which cases does the user wants to dump the messages to the console? Is it only for debugging / to review the messages?

---------------------------------------------------------------------------

by michelsalib at 2011/09/06 01:33:58 -0700

@kaiwa Your concerns seems perfectly right. Initially I just wanted to mimic the `doctrine:schema:update` command. But it can be changed.
@fabpot what do you think ?

---------------------------------------------------------------------------

by michelsalib at 2011/09/06 02:01:22 -0700

@stloyd I tried to do a `git rebase` and I am quite lost. I think I messed something when merge instead of rebase. How can I clean/squash my PR properly ?

---------------------------------------------------------------------------

by stloyd at 2011/09/06 02:11:29 -0700

@michelsalib for now just work as it is ;-) When I back from work I will try to help you to rebase it properly.

---------------------------------------------------------------------------

by michelsalib at 2011/09/06 02:12:17 -0700

@stloyd Thank you !

---------------------------------------------------------------------------

by stloyd at 2011/09/06 12:39:18 -0700

@michelsalib I was trying to rebase your code and revert it this _bad_ commit (merge), but without success.

IMO best way would be making _brand new_ branch based on symfony/master, and the `git cherry-pick` ([hint](http://ariejan.net/2010/06/10/cherry-picking-specific-commits-from-another-branch)) commits you need (almost all in this PR but without this one with merge), then you will need to apply again _by hand_ changes from commit fce24c7fa2 (this clean up you have done there).

I'm sorry I can't give you more help...

---------------------------------------------------------------------------

by michelsalib at 2011/09/07 09:41:36 -0700

@stloyd I finally succeed to fix this PR. Thanks to your help! I must admit the whole thing with `cherry-pick` command was quite epic.
@fabpot Don't be sorry, I am about to fix the PHPDoc. I shall squash right after.

---------------------------------------------------------------------------

by michelsalib at 2011/09/07 10:07:20 -0700

I just squashed and did a last polish of the code. You might want to read it again before merge.

---------------------------------------------------------------------------

by fabpot at 2011/09/07 11:40:48 -0700

 ok, code looks really good now. I think the only missing thing is some more unit tests (for the extractors for instance).

---------------------------------------------------------------------------

by michelsalib at 2011/09/07 12:18:59 -0700

Thanks, I'll look into it tomorrow.

---------------------------------------------------------------------------

by michelsalib at 2011/09/08 15:13:10 -0700

Hi,
I just added unit tests for both extractors (php and yaml).
Concerning the yaml extractor, the test is quite tricky because I need to mock the twig environment while returning a twig tree that contain a trans block and a trans filter. But it work fine with as little side effects as possible. Also I am not sure that the test should be in the TwigBundle.
IMO, the PR seems ready.

---------------------------------------------------------------------------

by stof at 2011/09/08 15:34:41 -0700

As the extractor is in bridge, the tests should be in the folder containing the tests for the bridge in tests/

---------------------------------------------------------------------------

by michelsalib at 2011/09/08 15:41:45 -0700

Thanks @stof, it is now fixed.

---------------------------------------------------------------------------

by michelsalib at 2011/09/09 00:48:47 -0700

thanks @stloyd, it is fixed now.

---------------------------------------------------------------------------

by michelsalib at 2011/09/09 01:25:24 -0700

Fixed again ;)
This commit is contained in:
Fabien Potencier 2011-09-09 11:17:04 +02:00
commit b99bb1e31b
30 changed files with 1002 additions and 47 deletions

View File

@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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'));
}
}

View File

@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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(<<<EOF
The <info>translation:update</info> 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.
<info>php app/console translation:update --dump-messages en AcmeBundle</info>
<info>php app/console translation:update --force --prefix="new_" fr AcmeBundle</info>
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('<info>You must choose one of --force or --dump-messages</info>');
return;
}
// check format
$writer = $this->getContainer()->get('translation.writer');
$supportedFormats = $writer->getFormats();
if (!in_array($input->getOption('output-format'), $supportedFormats)) {
$output->writeln('<error>Wrong output format</error>');
$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 "<info>%s</info>" translation files for "<info>%s</info>"', $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 <info>%s</info>:\n", $domain));
$output->writeln(Yaml::dump($catalogue->all($domain),10));
}
if($input->getOption('output-format') == 'xliff')
$output->writeln('Xliff output version is <info>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));
}
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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)));
}
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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)));
}
}
}

View File

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

View File

@ -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);

View File

@ -13,11 +13,15 @@
<parameter key="translation.loader.xliff.class">Symfony\Component\Translation\Loader\XliffFileLoader</parameter>
<parameter key="translation.loader.qt.class">Symfony\Component\Translation\Loader\QtTranslationsLoader</parameter>
<parameter key="translation.loader.csv.class">Symfony\Component\Translation\Loader\CsvFileLoader</parameter>
<parameter key="translation.dumper.php.class">Symfony\Component\Translation\Dumper\PhpDumper</parameter>
<parameter key="translation.dumper.xliff.class">Symfony\Component\Translation\Dumper\XliffDumper</parameter>
<parameter key="translation.dumper.yml.class">Symfony\Component\Translation\Dumper\YamlDumper</parameter>
<parameter key="translation.dumper.qt.class">Symfony\Component\Translation\Dumper\QtTranslationsDumper</parameter>
<parameter key="translation.dumper.csv.class">Symfony\Component\Translation\Dumper\CsvDumper</parameter>
<parameter key="translation.dumper.php.class">Symfony\Component\Translation\Dumper\PhpFileDumper</parameter>
<parameter key="translation.dumper.xliff.class">Symfony\Component\Translation\Dumper\XliffFileDumper</parameter>
<parameter key="translation.dumper.yml.class">Symfony\Component\Translation\Dumper\YamlFileDumper</parameter>
<parameter key="translation.dumper.qt.class">Symfony\Component\Translation\Dumper\QtFileDumper</parameter>
<parameter key="translation.dumper.csv.class">Symfony\Component\Translation\Dumper\CsvFileDumper</parameter>
<parameter key="translation.extractor.php.class">Symfony\Bundle\FrameworkBundle\Translation\PhpExtractor</parameter>
<parameter key="translation.loader.class">Symfony\Bundle\FrameworkBundle\Translation\TranslationLoader</parameter>
<parameter key="translation.extractor.class">Symfony\Component\Translation\Extractor\ChainExtractor</parameter>
<parameter key="translation.writer.class">Symfony\Component\Translation\Writer\TranslationWriter</parameter>
</parameters>
<services>
@ -77,5 +81,15 @@
<service id="translation.dumper.csv" class="%translation.dumper.csv.class%">
<tag name="translation.dumper" alias="csv" />
</service>
<service id="translation.extractor.php" class="%translation.extractor.php.class%">
<tag name="translation.extractor" alias="php" />
</service>
<service id="translation.loader" class="%translation.loader.class%"/>
<service id="translation.extractor" class="%translation.extractor.class%"/>
<service id="translation.writer" class="%translation.writer.class%"/>
</services>
</container>

View File

@ -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);

View File

@ -0,0 +1,2 @@
This template is used for translation message extraction tests
<?php echo $view['translator']->trans('new key') ?>

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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');
}
}

View File

@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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;
}
}
}
}
}

View File

@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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));
}
}
}
}

View File

@ -16,6 +16,7 @@
<parameter key="twig.extension.routing.class">Symfony\Bridge\Twig\Extension\RoutingExtension</parameter>
<parameter key="twig.extension.yaml.class">Symfony\Bridge\Twig\Extension\YamlExtension</parameter>
<parameter key="twig.extension.form.class">Symfony\Bridge\Twig\Extension\FormExtension</parameter>
<parameter key="twig.translation.extractor.class">Symfony\Bridge\Twig\Translation\TwigExtractor</parameter>
<parameter key="twig.exception_listener.class">Symfony\Component\HttpKernel\EventListener\ExceptionListener</parameter>
</parameters>
@ -76,6 +77,11 @@
<argument>%twig.form.resources%</argument>
</service>
<service id="twig.translation.extractor" class="%twig.translation.extractor.class%">
<argument type="service" id="twig" />
<tag name="translation.extractor" alias="twig" />
</service>
<service id="twig.exception_listener" class="%twig.exception_listener.class%">
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" priority="-128" />
<tag name="monolog.logger" channel="request" />

View File

@ -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';
}
}

View File

@ -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 <michelsalib@hotmail.com>
*/
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());
}

View File

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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();
}

View File

@ -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 <michelsalib@hotmail.com>
*/
class PhpDumper implements DumperInterface
class PhpFileDumper extends FileDumper
{
/**
* {@inheritDoc}
*/
public function dump(MessageCatalogue $messages, $domain = 'messages')
protected function format(MessageCatalogue $messages, $domain)
{
$output = "<?php\n\nreturn ".var_export($messages->all($domain), true).";\n";
return $output;
}
/**
* {@inheritDoc}
*/
protected function getExtension()
{
return 'php';
}
}

View File

@ -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 <kontakt@beberlei.de>
*/
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';
}
}

View File

@ -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 <michelsalib@hotmail.com>
*/
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';
}
}

View File

@ -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 <michelsalib@hotmail.com>
*/
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';
}
}

View File

@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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);
}
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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);
}

View File

@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <michelsalib@hotmail.com>
*/
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);
}
}

View File

@ -0,0 +1 @@
stub file that won't be parsed

View File

@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}