From c923b2ab885f2fce2059a3dcd2a8a643fcb2456b Mon Sep 17 00:00:00 2001 From: Abdellatif Ait boudad Date: Fri, 20 Mar 2015 23:12:36 +0000 Subject: [PATCH] [Translation][Profiler] Added a Translation profiler. --- .../FrameworkExtension.php | 15 +- .../Resources/config/translation_debug.xml | 19 +++ .../views/Collector/translation.html.twig | 93 +++++++++++ .../TranslationDataCollector.php | 132 +++++++++++++++ .../Translation/DataCollectorTranslator.php | 153 ++++++++++++++++++ .../Translation/LoggingTranslator.php | 2 +- .../Tests/DataCollectorTranslatorTest.php | 72 +++++++++ 7 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_debug.xml create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig create mode 100644 src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php create mode 100644 src/Symfony/Component/Translation/DataCollectorTranslator.php create mode 100644 src/Symfony/Component/Translation/Tests/DataCollectorTranslatorTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 21e43e6f60..c1d6e30b6d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -35,6 +35,7 @@ use Symfony\Component\Validator\Validation; class FrameworkExtension extends Extension { private $formConfigEnabled = false; + private $translationConfigEnabled = false; private $sessionConfigEnabled = false; /** @@ -116,8 +117,8 @@ class FrameworkExtension extends Extension $this->registerEsiConfiguration($config['esi'], $container, $loader); $this->registerSsiConfiguration($config['ssi'], $container, $loader); $this->registerFragmentsConfiguration($config['fragments'], $container, $loader); - $this->registerProfilerConfiguration($config['profiler'], $container, $loader); $this->registerTranslatorConfiguration($config['translator'], $container); + $this->registerProfilerConfiguration($config['profiler'], $container, $loader); if (isset($config['router'])) { $this->registerRouterConfiguration($config['router'], $container, $loader); @@ -288,10 +289,15 @@ class FrameworkExtension extends Extension $loader->load('profiling.xml'); $loader->load('collectors.xml'); - if (true === $this->formConfigEnabled) { + if ($this->formConfigEnabled) { $loader->load('form_debug.xml'); } + if ($this->translationConfigEnabled) { + $loader->load('translation_debug.xml'); + $container->getDefinition('translator.data_collector')->setDecoratedService('translator'); + } + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); $container->setParameter('profiler_listener.only_master_requests', $config['only_master_requests']); @@ -644,6 +650,7 @@ class FrameworkExtension extends Extension if (!$this->isConfigEnabled($container, $config)) { return; } + $this->translationConfigEnabled = true; // Use the "real" translator instead of the identity default $container->setAlias('translator', 'translator.default'); @@ -865,9 +872,9 @@ class FrameworkExtension extends Extension /** * Loads the serializer configuration. * - * @param array $config A serializer configuration array + * @param array $config A serializer configuration array * @param ContainerBuilder $container A ContainerBuilder instance - * @param XmlFileLoader $loader An XmlFileLoader instance + * @param XmlFileLoader $loader An XmlFileLoader instance */ private function registerSerializerConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_debug.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_debug.xml new file mode 100644 index 0000000000..37b5cd8de8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_debug.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig new file mode 100644 index 0000000000..2a86e4648a --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig @@ -0,0 +1,93 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% import _self as translator %} + +{% block toolbar %} + {% if collector.messages|length %} + {% set icon %} + + {% if collector.countMissings %} + {% set status_color = "red" %} + {% elseif collector.countFallbacks %} + {% set status_color = "yellow" %} + {% endif %} + {% set error_count = collector.countMissings + collector.countFallbacks %} + {{ error_count ?: collector.countdefines }} + {% endset %} + {% set text %} + {% if collector.countMissings %} +
+ Missing messages + {{ collector.countMissings }} +
+ {% endif %} + {% if collector.countFallbacks %} +
+ Fallback messages + {{ collector.countFallbacks }} +
+ {% endif %} + {% if collector.countdefines %} +
+ Defined messages + {{ collector.countdefines }} +
+ {% endif %} + {% endset %} + {% include '@WebProfiler/Profiler/toolbar_item.html.twig' with { 'link': profiler_url } %} + {% endif %} +{% endblock %} + +{% block menu %} + + + Translation + +{% endblock %} + +{% block panel %} + {% if collector.messages is empty %} +

Translations

+

+ No translations have been called. +

+ {% else %} + {{ block('panelContent') }} + {% endif %} +{% endblock %} + +{% block panelContent %} +

Called Translations

+ + + + + + + + + + + {% for message in collector.messages %} + + + + + + + + {% endfor %} +
StateLocaleDomainIdMessage Preview
{{ translator.state(message) }}{{ message.locale }}{{ message.domain }}{{ message.id }}{{ message.translation }}
+{% endblock %} + +{% macro state(translation) %} + {% if translation.state == constant('Symfony\\Component\\Translation\\DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK') %} + same as fallback + {% elseif translation.state == constant('Symfony\\Component\\Translation\\DataCollectorTranslator::MESSAGE_MISSING') %} + missing + {% endif %} +{% endmacro %} diff --git a/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php b/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php new file mode 100644 index 0000000000..928e2fe8ac --- /dev/null +++ b/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\DataCollector; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\Translation\DataCollectorTranslator; + +/** + * @author Abdellatif Ait boudad + */ +class TranslationDataCollector extends DataCollector implements LateDataCollectorInterface +{ + /** + * @var DataCollectorTranslator + */ + private $translator; + + /** + * @param DataCollectorTranslator $translator + */ + public function __construct(DataCollectorTranslator $translator) + { + $this->translator = $translator; + } + + /** + * {@inheritdoc} + */ + public function lateCollect() + { + $this->data = $this->computeCount(); + $this->data['messages'] = $this->sanitizeCollectedMessages($this->translator->getCollectedMessages()); + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + } + + /** + * @return array + */ + public function getMessages() + { + return isset($this->data['messages']) ? $this->data['messages'] : array(); + } + + /** + * @return int + */ + public function getCountMissings() + { + return isset($this->data[DataCollectorTranslator::MESSAGE_MISSING]) ? $this->data[DataCollectorTranslator::MESSAGE_MISSING] : 0; + } + + /** + * @return int + */ + public function getCountFallbacks() + { + return isset($this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK]) ? $this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK] : 0; + } + + /** + * @return int + */ + public function getCountDefines() + { + return isset($this->data[DataCollectorTranslator::MESSAGE_DEFINED]) ? $this->data[DataCollectorTranslator::MESSAGE_DEFINED] : 0; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'translation'; + } + + private function sanitizeCollectedMessages($messages) + { + foreach ($messages as $key => $message) { + $messages[$key]['translation'] = $this->sanitizeString($messages[$key]['translation']); + } + + return $messages; + } + + private function computeCount() + { + $count = array( + DataCollectorTranslator::MESSAGE_DEFINED => 0, + DataCollectorTranslator::MESSAGE_MISSING => 0, + DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK => 0, + ); + + foreach ($this->translator->getCollectedMessages() as $message) { + ++$count[$message['state']]; + } + + return $count; + } + + private function sanitizeString($string, $length = 80) + { + $string = trim(preg_replace('/\s+/', ' ', $string)); + + if (function_exists('mb_strlen') && false !== $encoding = mb_detect_encoding($string)) { + if (mb_strlen($string, $encoding) > $length) { + return mb_substr($string, 0, $length - 3, $encoding).'...'; + } + } elseif (strlen($string) > $length) { + return substr($string, 0, $length - 3).'...'; + } + + return $string; + } +} diff --git a/src/Symfony/Component/Translation/DataCollectorTranslator.php b/src/Symfony/Component/Translation/DataCollectorTranslator.php new file mode 100644 index 0000000000..99c26f182f --- /dev/null +++ b/src/Symfony/Component/Translation/DataCollectorTranslator.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +/** + * @author Abdellatif Ait boudad + */ +class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInterface +{ + const MESSAGE_DEFINED = 0; + const MESSAGE_MISSING = 1; + const MESSAGE_EQUALS_FALLBACK = 2; + + /** + * @var TranslatorInterface + */ + private $translator; + + /** + * @var array + */ + private $messages = array(); + + /** + * @param Translator $translator + */ + public function __construct(TranslatorInterface $translator) + { + if (!($translator instanceof TranslatorInterface && $translator instanceof TranslatorBagInterface)) { + throw new \InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface and TranslatorBagInterface.', get_class($translator))); + } + + $this->translator = $translator; + } + + /** + * {@inheritdoc} + */ + public function trans($id, array $parameters = array(), $domain = null, $locale = null) + { + $trans = $this->translator->trans($id, $parameters, $domain, $locale); + $this->collectMessage($locale, $domain, $id, $trans); + + return $trans; + } + + /** + * {@inheritdoc} + */ + public function transChoice($id, $number, array $parameters = array(), $domain = null, $locale = null) + { + $trans = $this->translator->transChoice($id, $number, $parameters, $domain, $locale); + $this->collectMessage($locale, $domain, $id, $trans); + + return $trans; + } + + /** + * {@inheritdoc} + * + * @api + */ + public function setLocale($locale) + { + $this->translator->setLocale($locale); + } + + /** + * {@inheritdoc} + * + * @api + */ + public function getLocale() + { + return $this->translator->getLocale(); + } + + /** + * {@inheritdoc} + */ + public function getCatalogue($locale = null) + { + return $this->translator->getCatalogue($locale); + } + + /** + * Passes through all unknown calls onto the translator object. + */ + public function __call($method, $args) + { + return call_user_func_array(array($this->translator, $method), $args); + } + + /** + * @return array + */ + public function getCollectedMessages() + { + return $this->messages; + } + + /** + * @param string|null $locale + * @param string|null $domain + * @param string $id + * @param string $trans + */ + private function collectMessage($locale, $domain, $id, $translation) + { + if (null === $locale) { + $locale = $this->getLocale(); + } + + if (null === $domain) { + $domain = 'messages'; + } + + $id = (string) $id; + $catalogue = $this->translator->getCatalogue($locale); + if ($catalogue->defines($id, $domain)) { + $state = self::MESSAGE_DEFINED; + } elseif ($catalogue->has($id, $domain)) { + $state = self::MESSAGE_EQUALS_FALLBACK; + + $fallbackCatalogue = $catalogue->getFallBackCatalogue(); + while ($fallbackCatalogue) { + if ($fallbackCatalogue->defines($id, $domain)) { + $locale = $fallbackCatalogue->getLocale(); + break; + } + } + } else { + $state = self::MESSAGE_MISSING; + } + + $this->messages[] = array( + 'locale' => $locale, + 'domain' => $domain, + 'id' => $id, + 'translation' => $translation, + 'state' => $state, + ); + } +} diff --git a/src/Symfony/Component/Translation/LoggingTranslator.php b/src/Symfony/Component/Translation/LoggingTranslator.php index 851188230b..b46099c68f 100644 --- a/src/Symfony/Component/Translation/LoggingTranslator.php +++ b/src/Symfony/Component/Translation/LoggingTranslator.php @@ -35,7 +35,7 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface public function __construct($translator, LoggerInterface $logger) { if (!($translator instanceof TranslatorInterface && $translator instanceof TranslatorBagInterface)) { - throw new \InvalidArgumentException(sprintf('The Translator "%s" must implements TranslatorInterface and TranslatorBagInterface.', get_class($translator))); + throw new \InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface and TranslatorBagInterface.', get_class($translator))); } $this->translator = $translator; diff --git a/src/Symfony/Component/Translation/Tests/DataCollectorTranslatorTest.php b/src/Symfony/Component/Translation/Tests/DataCollectorTranslatorTest.php new file mode 100644 index 0000000000..1e93cb4164 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/DataCollectorTranslatorTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests; + +use Symfony\Component\Translation\Translator; +use Symfony\Component\Translation\DataCollectorTranslator; +use Symfony\Component\Translation\Loader\ArrayLoader; + +class DataCollectorTranslatorTest extends \PHPUnit_Framework_TestCase +{ + protected function setUp() + { + if (!class_exists('Symfony\Component\HttpKernel\DataCollector\DataCollector')) { + $this->markTestSkipped('The "DataCollector" is not available'); + } + } + public function testCollectMessages() + { + $collector = $this->createCollector(); + $collector->setFallbackLocales(array('fr')); + + $collector->trans('foo'); + $collector->trans('bar'); + $collector->transChoice('choice', 0); + + $expectedMessages = array(); + $expectedMessages[] = array( + 'id' => 'foo', + 'translation' => 'foo (en)', + 'locale' => 'en', + 'domain' => 'messages', + 'state' => DataCollectorTranslator::MESSAGE_DEFINED, + ); + $expectedMessages[] = array( + 'id' => 'bar', + 'translation' => 'bar (fr)', + 'locale' => 'fr', + 'domain' => 'messages', + 'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK, + ); + $expectedMessages[] = array( + 'id' => 'choice', + 'translation' => 'choice', + 'locale' => 'en', + 'domain' => 'messages', + 'state' => DataCollectorTranslator::MESSAGE_MISSING, + ); + + $this->assertEquals($expectedMessages, $collector->getCollectedMessages()); + } + + private function createCollector() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', array('foo' => 'foo (en)'), 'en'); + $translator->addResource('array', array('bar' => 'bar (fr)'), 'fr'); + + $collector = new DataCollectorTranslator($translator); + + return $collector; + } +}