8000 feature #10368 [FrameworkBundle] Added a translation:debug command (f… · symfony/symfony@786c956 · GitHub
[go: up one dir, main page]

Skip to content

Commit 786c956

Browse files
committed
feature #10368 [FrameworkBundle] Added a translation:debug command (fabpot)
This PR was merged into the 2.5-dev branch. Discussion ---------- [FrameworkBundle] Added a translation:debug command | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | | License | MIT | Doc PR | This PR is based on #10076 but fixes a bunch of issues for edge cases. It also uses the new Table helper. Original description: This pull request adds a `translation:debug` command that can be used to inspect unused / missing messages and compare them with the fallback ones (for example to detect copy pasted strings). This is inspired by this ruby project https://github.com/glebm/i18n-tasks Example of output: ![image](https://f.cloud.github.com/assets/47313/2311673/84ae301c-a2f3-11e3-8bf6-96034369e46c.png) Commits ------- f039bde [FrameworkBundle] fixed edge cases for translation:debug and tweaked the output 597a310 Added a translation:debug command
2 parents 5ea6437 + f039bde commit 786c956

File tree

2 files changed

+232
-0
lines changed

2 files changed

+232
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
2.5.0
55
-----
66

7+
* Added `translation:debug` command
78
* Added `config:debug` command
89
* Added `yaml:lint` command
910
* Deprecated the `RouterApacheDumperCommand` which will be removed in Symfony 3.0.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
< 10000 code>10+
*/
11+
12+
namespace Symfony\Bundle\FrameworkBundle\Command;
13+
14+
use Symfony\Component\Translation\Catalogue\MergeOperation;
15+
use Symfony\Component\Console\Helper\Table;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputOption;
20+
use Symfony\Component\Translation\MessageCatalogue;
21+
use Symfony\Component\Translation\Translator;
22+
23+
/**
24+
* Helps finding unused or missing translation messages in a given locale
25+
* and comparing them with the fallback ones.
26+
*
27+
* @author Florian Voutzinos <florian@voutzinos.com>
28+
*/
29+
class TranslationDebugCommand extends ContainerAwareCommand
30+
{
31+
const MESSAGE_MISSING = 0;
32+
const MESSAGE_UNUSED = 1;
33+
const MESSAGE_EQUALS_FALLBACK = 2;
34+
35+
/**
36+
* {@inheritdoc}
37+
*/
38+
protected function configure()
39+
{
40+
$this
41+
->setName('translation:debug')
42+
->setDefinition(array(
43+
new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
44+
new InputArgument('bundle', InputArgument::REQUIRED, 'The bundle name'),
45+
new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'The messages domain'),
46+
new InputOption('only-missing', null, InputOption::VALUE_NONE, 'Displays only missing messages'),
47+
new InputOption('only-unused', null, InputOption::VALUE_NONE, 'Displays only unused messages'),
48+
))
49+
->setDescription('Displays translation messages informations')
50+
->setHelp(<<<EOF
51+
The <info>%command.name%</info> command helps finding unused or missing translation
52+
messages and comparing them with the fallback ones by inspecting the
53+
templates and translation files of a given bundle.
54+
55+
You can display information about bundle translations in a specific locale:
56+
57+
<info>php %command.full_name% en AcmeDemoBundle</info>
58+
59+
You can also specify a translation domain for the search:
60+
61+
<info>php %command.full_name% --domain=messages en AcmeDemoBundle</info>
62+
63+
You can only display missing messages:
64+
65+
<info>php %command.full_name% --only-missing en AcmeDemoBundle</info>
66+
67+
You can only display unused messages:
68+
69+
<info>php %command.full_name% --only-unused en AcmeDemoBundle</info>
70+
EOF
71+
)
72+
;
73+
}
74+
75+
/**
76+
* {@inheritdoc}
77+
*/
78+
protected function execute(InputInterface $input, OutputInterface $output)
79+
{
80+
$locale = $input->getArgument('locale');
81+
$domain = $input->getOption('domain');
82+
$bundle = $this->getContainer()->get('kernel')->getBundle($input->getArgument('bundle'));
83+
$loader = $this->getContainer()->get('translation.loader');
84+
85+
// Extract used messages
86+
$extractedCatalogue = new MessageCatalogue($locale);
87+
$this->getContainer()->get('translation.extractor')->extract($bundle->getPath().'/Resources/views', $extractedCatalogue);
88+
89+
// Load defined messages
90+
$currentCatalogue = new MessageCatalogue($locale);
91+
if (is_dir($bundle->getPath().'/Resources/translations')) {
92+
$loader->loadMessages($bundle->getPath().'/Resources/translations', $currentCatalogue);
93+
}
94+
95+
// Merge defined and extracted messages to get all message ids
96+
$mergeOperation = new MergeOperation($extractedCatalogue, $currentCatalogue);
97+
$allMessages = $mergeOperation->getResult()->all($domain);
98+
if (null !== $domain) {
99+
$allMessages = array($domain => $allMessages);
100+
}
101+
102+
// No defined or extracted messages
103+
if (empty($allMessages) || null !== $domain && empty($allMessages[$domain])) {
104+
$outputMessage = sprintf('<info>No defined or extracted messages for locale "%s"</info>', $locale);
105+
106+
if (null !== $domain) {
107+
$outputMessage .= sprintf(' <info>and domain "%s"</info>', $domain);
108+
}
109+
110+
$output->writeln($outputMessage);
111+
112+
return;
113+
}
114+
115+
// Load the fallback catalogues
116+
$fallbackCatalogues = array();
117+
$translator = $this->getContainer()->get('translator');
118+
if ($translator instanceof Translator) {
119+
foreach ($translator->getFallbackLocales() as $fallbackLocale) {
120+
if ($fallbackLocale === $locale) {
121+
continue;
122+
}
123+
124+
$fallbackCatalogue = new MessageCatalogue($fallbackLocale);
125+
$loader->loadMessages($bundle->getPath().'/Resources/translations', $fallbackCatalogue);
126+
$fallbackCatalogues[] = $fallbackCatalogue;
127+
}
128+
}
129+
130+
/** @var \Symfony\Component\Console\Helper\Table $table */
131+
$table = new Table($output);
132+
133+
// Display header line
134+
$headers = array('State(s)', 'Id', sprintf('Message Preview (%s)', $locale));
135+
foreach ($fallbackCatalogues as $fallbackCatalogue) {
136+
$headers[] = sprintf('Fallback Message Preview (%s)', $fallbackCatalogue->getLocale());
137+
}
138+
$table->setHeaders($headers);
139+
140+
// Iterate all message ids and determine their state
141+
foreach ($allMessages as $domain => $messages) {
142+
foreach (array_keys($messages) as $messageId) {
143+
$value = $currentCatalogue->get($messageId, $domain);
144+
$states = array();
145+
146+
if ($extractedCatalogue->defines($messageId, $domain)) {
147+
if (!$currentCatalogue->defines($messageId, $domain)) {
148+
$states[] = self::MESSAGE_MISSING;
149+
}
150+
} elseif ($currentCatalogue->defines($messageId, $domain)) {
151+
$states[] = self::MESSAGE_UNUSED;
152+
}
153+
154+
if (!in_array(self::MESSAGE_UNUSED, $states) && true === $input->getOption('only-unused')
155+
|| !in_array(self::MESSAGE_MISSING, $states) && true === $input->getOption('only-missing')) {
156+
continue;
157+
}
158+
159+
foreach ($fallbackCatalogues as $fallbackCatalogue) {
160+
if ($fallbackCatalogue->defines($messageId, $domain) && $value === $fallbackCatalogue->get($messageId, $domain)) {
161+
$states[] = self::MESSAGE_EQUALS_FALLBACK;
162+
163+
break;
164+
}
165+
}
166+
167+
$row = array($this->formatStates($states), $this->formatId($messageId), $this->sanitizeString($value));
168+
foreach ($fallbackCatalogues as $fallbackCatalogue) {
169+
$row[] = $this->sanitizeString($fallbackCatalogue->get($messageId, $domain));
170+
}
171+
172+
$table->addRow($row);
173+
}
174+
}
175+
176+
$table->render();
177+
178+
$output->writeln('');
179+
$output->writeln('<info>Legend:</info>');
180+
$output->writeln(sprintf(' %s Missing message', $this->formatState(self::MESSAGE_MISSING)));
181+
$output->writeln(sprintf(' %s Unused message', $this->formatState(self::MESSAGE_UNUSED)));
182+
$output->writeln(sprintf(' %s Same as the fallback message', $this->formatState(self::MESSAGE_EQUALS_FALLBACK)));
183+
}
184+
185+
private function formatState($state)
186+
{
187+
if (self::MESSAGE_MISSING === $state) {
188+
return '<fg=red>x</>';
189+
}
190+
191+
if (self::MESSAGE_UNUSED === $state) {
192+
return '<fg=yellow>o</>';
193+
}
194+
195+
if (self::MESSAGE_EQUALS_FALLBACK === $state) {
196+
return '<fg=green>=</>';
197+
}
198+
199+
return $state;
200+
}
201+
202+
private function formatStates(array $states)
203+
{
204+
$result = array();
205+
foreach ($states as $state) {
206+
$result[] = $this->formatState($state);
207+
}
208+
209+
return implode(' ', $result);
210+
}
211+
212+
private function formatId($id)
213+
{
214+
return sprintf('<fg=cyan;options=bold>%s</fg=cyan;options=bold>', $id);
215+
}
216+
217+
private function sanitizeString($string, $length = 40)
218+
{
219+
$string = trim(preg_replace('/\s+/', ' ', $string));
220+
221+
if (function_exists('mb_strlen') && false !== $encoding = mb_detect_encoding($string)) {
222+
if (mb_strlen($string, $encoding) > $length) {
223+
return mb_substr($string, 0, $length - 3, $encoding).'...';
224+
}
225+
} elseif (strlen($string) > $length) {
226+
return substr($string, 0, $length - 3).'...';
227+
}
228+
229+
return $string;
230+
}
231+
}

0 commit comments

Comments
 (0)
0