From 92100ea2e06de6967ce309cd9ef73043206117c5 Mon Sep 17 00:00:00 2001 From: Olivier Dolbeau Date: Mon, 27 Apr 2020 01:21:26 +0200 Subject: [PATCH 01/14] Added boilerplate for Remote Storages and read method --- .../Command/TranslationPullCommand.php | 120 +++++++++++ .../Command/TranslationPushCommand.php | 152 ++++++++++++++ .../DependencyInjection/Configuration.php | 19 ++ .../FrameworkExtension.php | 39 ++++ .../Resources/config/translation_remotes.xml | 17 ++ .../Translation/Bridge/Loco/LocoRemote.php | 103 +++++++++ .../Bridge/Loco/LocoRemoteFactory.php | 46 ++++ .../Exception/TransportException.php | 43 ++++ .../Exception/TransportExceptionInterface.php | 22 ++ .../Translation/Loader/XliffRawLoader.php | 196 ++++++++++++++++++ .../Translation/Reader/TranslationReader.php | 3 + .../Translation/Remote/AbstractRemote.php | 72 +++++++ .../Remote/AbstractRemoteFactory.php | 61 ++++++ .../Component/Translation/Remote/Dsn.php | 108 ++++++++++ .../Translation/Remote/NullRemote.php | 40 ++++ .../Translation/Remote/NullRemoteFactory.php | 34 +++ .../Translation/Remote/RemoteDecorator.php | 55 +++++ .../Remote/RemoteFactoryInterface.php | 26 +++ .../Translation/Remote/RemoteInterface.php | 40 ++++ src/Symfony/Component/Translation/Remotes.php | 55 +++++ .../Component/Translation/RemotesFactory.php | 96 +++++++++ .../Component/Translation/TranslatorBag.php | 49 +++++ 22 files changed, 1396 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml create mode 100644 src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php create mode 100644 src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php create mode 100644 src/Symfony/Component/Translation/Exception/TransportException.php create mode 100644 src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php create mode 100644 src/Symfony/Component/Translation/Loader/XliffRawLoader.php create mode 100644 src/Symfony/Component/Translation/Remote/AbstractRemote.php create mode 100644 src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php create mode 100644 src/Symfony/Component/Translation/Remote/Dsn.php create mode 100644 src/Symfony/Component/Translation/Remote/NullRemote.php create mode 100644 src/Symfony/Component/Translation/Remote/NullRemoteFactory.php create mode 100644 src/Symfony/Component/Translation/Remote/RemoteDecorator.php create mode 100644 src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php create mode 100644 src/Symfony/Component/Translation/Remote/RemoteInterface.php create mode 100644 src/Symfony/Component/Translation/Remotes.php create mode 100644 src/Symfony/Component/Translation/RemotesFactory.php create mode 100644 src/Symfony/Component/Translation/TranslatorBag.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php new file mode 100644 index 0000000000000..3f9cf7c38e3ac --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -0,0 +1,120 @@ + + * + * 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\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Translation\Catalogue\MergeOperation; +use Symfony\Component\Translation\Catalogue\TargetOperation; +use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Remotes; +use Symfony\Component\Translation\Writer\TranslationWriterInterface; + +/** + * A command that parses templates to extract translation messages and adds them + * into the translation files. + * + * @final + */ +class TranslationPullCommand extends Command +{ + protected static $defaultName = 'translation:pull'; + + private $remotes; + private $writer; + private $reader; + private $defaultLocale; + private $defaultTransPath; + private $enabledLocales; + + public function __construct(Remotes $remotes, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, string $defaultTransPath = null, array $enabledLocales = []) + { + $this->remotes = $remotes; + $this->writer = $writer; + $this->reader = $reader; + $this->defaultLocale = $defaultLocale; + $this->defaultTransPath = $defaultTransPath; + $this->enabledLocales = $enabledLocales; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $keys = $this->remotes->keys(); + $defaultRemote = 1 === count($keys) ? $keys[0] : null; + + $this + ->setDefinition([ + new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to pull translations from.', $defaultRemote), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with updated ones'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locels to pull'), + new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'), + new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'), + ]) + ->setDescription('Pull translations from a given remote.') + ->setHelp(<<<'EOF' +The %command.name% pull translations from the given remote. Only +new translations are pulled, existing ones are not overwriten. + +You can overwrite existing translations: + + php %command.full_name% --force remote + +You can remote local translations which are not present on the remote: + + php %command.full_name% --delete-absolete remote + +Full example: + + php %command.full_name% remote --force --delete-obslete --domains=messages,validators --locales=en + +This command will pull all translations linked to domains messages & validators +for the locale en. Local translations for the specified domains & locale will +be erased if they're not present on the remote and overwriten if it's the +case. Local translations for others domains & locales will be ignored. +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $remoteTranslations = $this->remotes->get($input->getArgument('remote'))->read(); + + //$this->writer->write($operation->getResult(), $input->getOption('output-format'), [ + //'path' => $bundleTransPath, + //'default_locale' => $this->defaultLocale, + //'xliff_version' => $input->getOption('xliff-version') + //]); + + dump($this->remotes); + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php new file mode 100644 index 0000000000000..f3957845ba328 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -0,0 +1,152 @@ + + * + * 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\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Translation\Catalogue\MergeOperation; +use Symfony\Component\Translation\Catalogue\TargetOperation; +use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Remotes; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\Writer\TranslationWriterInterface; + +/** + * @final + */ +class TranslationPushCommand extends Command +{ + protected static $defaultName = 'translation:push'; + + private $remotes; + private $reader; + private $defaultTransPath; + private $transPaths; + private $enabledLocales; + + public function __construct(Remotes $remotes, TranslationReaderInterface $reader, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) + { + $this->remotes = $remotes; + $this->reader = $reader; + $this->defaultTransPath = $defaultTransPath; + $this->transPaths = $transPaths; + $this->enabledLocales = $enabledLocales; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $keys = $this->remotes->keys(); + $defaultRemote = 1 === count($keys) ? $keys[0] : null; + + $this + ->setDefinition([ + new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to pull translations from.', $defaultRemote), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with updated ones'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locels to pull', $this->enabledLocales), + new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'), + new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'), + ]) + ->setDescription('Push translations to a given remote.') + ->setHelp(<<<'EOF' +The %command.name% push translations to the given remote. Only new +translations are pushed, existing ones are not overwriten. + +You can overwrite existing translations: + + php %command.full_name% --force remote + +You can delete remote translations which are not present locally: + + php %command.full_name% --delete-absolete remote + +Full example: + + php %command.full_name% remote --force --delete-obslete --domains=messages,validators --locales=en + +This command will push all translations linked to domains messages & validators +for the locale en. Remote translations for the specified domains & locale will +be erased if they're not present locally and overwriten if it's the +case. Remote translations for others domains & locales will be ignored. +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (empty($this->enabledLocales)) { + throw new InvalidArgumentException('You must defined framework.translator.enabled_locales config key in order to work with remotes.'); + } + + $locales = $input->getOption('locales'); + $domains = $input->getOption('domains'); + + /** @var KernelInterface $kernel */ + $kernel = $this->getApplication()->getKernel(); + + $transPaths = $this->transPaths; + if ($this->defaultTransPath) { + $transPaths[] = $this->defaultTransPath; + } + + // Override with provided Bundle info + foreach ($kernel->getBundles() as $bundle) { + $bundleDir = $bundle->getPath(); + $transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundle->getPath().'/translations'; + } + + $translatorBag = new TranslatorBag(); + foreach ($locales as $locale) { + $translatorBag->addCatalogue($this->loadCurrentMessages($locale, $transPaths)); + } + + $remoteTranslations = $this->remotes + ->get($input->getArgument('remote')) + ->read($domains ?? $translatorBag->getDomains(), $locales); + + + // diff between $remoteTranslations and $localTranslations, + // then write to remote the diff (aka. new translation not yet in the remote storage) + return 0; + } + + private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue + { + $currentCatalogue = new MessageCatalogue($locale); + foreach ($transPaths as $path) { + if (is_dir($path)) { + $this->reader->read($path, $currentCatalogue); + } + } + + return $currentCatalogue; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index ee5818e0bbdbe..a709b0ca9a190 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -803,6 +803,25 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() + ->arrayNode('remotes') + ->info('Remotes you can pull/push your translations from') + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->scalarNode('dsn')->end() + ->arrayNode('domains') + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->arrayNode('locales') + ->prototype('scalar')->end() + ->defaultValue([]) + ->info('If not set, all locales listed under framework.translator.enabled_locales will be used.') + ->end() + ->end() + ->end() + ->defaultValue([]) + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index fd91bf6fa7c63..7e83a6aafb376 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -141,8 +141,10 @@ use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; +use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; +use Symfony\Component\Translation\Remote\RemoteInterface; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; @@ -1140,6 +1142,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder } $loader->load('translation.php'); + $loader->load('translation_remotes.xml'); // Use the "real" translator instead of the identity default $container->setAlias('translator', 'translator.default')->setPublic(true); @@ -1259,6 +1262,42 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $options, ]); } + + if (!empty($config['remotes'])) { + if (empty($config['enabled_locales'])) { + throw new LogicException('You must specify framework.translator.enabled_locales in order to use remotes.'); + } + + if ($container->hasDefinition('console.command.translation_pull')) { + $container->getDefinition('console.command.translation_pull') + ->replaceArgument(5, $transPaths) + ->replaceArgument(6, $config['enabled_locales']) + ; + } + + if ($container->hasDefinition('console.command.translation_push')) { + $container->getDefinition('console.command.translation_push') + ->replaceArgument(3, $transPaths) + ->replaceArgument(4, $config['enabled_locales']) + ; + } + + $container->getDefinition('translation.remotes_factory') + ->replaceArgument(1, $config['enabled_locales']) + ; + + $container->getDefinition('translation.remotes')->setArgument(0, $config['remotes']); + + $classToServices = [ + LocoRemoteFactory::class => 'translation.remote_factory.loco', + ]; + + foreach ($classToServices as $class => $service) { + if (!class_exists($class)) { + $container->removeDefinition($service); + } + } + } } private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml new file mode 100644 index 0000000000000..cc9dc1d9ea51e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php new file mode 100644 index 0000000000000..0feb03db8fdc3 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Loco; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Translation\Exception\RemoteException; +use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Loader\XliffRawLoader; +use Symfony\Component\Translation\Message\MessageInterface; +use Symfony\Component\Translation\Message\SmsMessage; +use Symfony\Component\Translation\Remote\AbstractRemote; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.1 + */ +final class LocoRemote extends AbstractRemote +{ + protected const HOST = 'localise.biz'; + + private $apiKey; + private $loader; + + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader) + { + $this->apiKey = $apiKey; + $this->loader = $loader; + + parent::__construct($client); + } + + public function __toString(): string + { + return sprintf('loco://%s', $this->getEndpoint()); + } + + /** + * {@inheritdoc} + */ + public function write(TranslatorBag $translations): void + { + } + + /** + * {@inheritdoc} + */ + public function read(array $domains, array $locales): TranslatorBag + { + $filter = $domains ? implode(',', $domains) : '*'; + + if (1 === count($locales)) { + $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locales[0], $filter), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + ]); + } else { + $response = $this->client->request('GET', sprintf('https://%s/api/export/all.xlf?filter=%s', $this->getEndpoint(), $filter), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + ]); + } + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException('Unable to read the Loco response: '.$response->getContent(false), $response); + } + + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + if (\count($domains) > 1) { + foreach ($domains as $domain) { + $translatorBag->addCatalogue($this->loader->load($response->getContent(), $locale, $domain)); + } + } else { + $translatorBag->addCatalogue($this->loader->load($response->getContent(), $locale, $domains[0] ?? 'messages')); // not sure + } + } + + return $translatorBag; + } + + /** + * {@inheritdoc} + */ + public function delete(TranslatorBag $translations): void + { + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php new file mode 100644 index 0000000000000..6eed45126f88c --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Loco; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Remote\AbstractRemoteFactory; +use Symfony\Component\Translation\Remote\Dsn; +use Symfony\Component\Translation\Remote\RemoteInterface; + +final class LocoRemoteFactory extends AbstractRemoteFactory +{ + /** + * @return LocoRemote + */ + public function create(Dsn $dsn): RemoteInterface + { + $scheme = $dsn->getScheme(); + $apiKey = $this->getUser($dsn); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('loco' === $scheme) { + return (new LocoRemote($apiKey, $this->client, $this->loader)) + ->setHost($host) + ->setPort($port) + ; + } + + throw new UnsupportedSchemeException($dsn, 'loco', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['loco']; + } +} diff --git a/src/Symfony/Component/Translation/Exception/TransportException.php b/src/Symfony/Component/Translation/Exception/TransportException.php new file mode 100644 index 0000000000000..427900e8e6740 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/TransportException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.1 + */ +class TransportException extends RuntimeException implements TransportExceptionInterface +{ + private $response; + private $debug = ''; + + public function __construct(string $message, ResponseInterface $response, int $code = 0, \Exception $previous = null) + { + $this->response = $response; + $this->debug .= $response->getInfo('debug') ?? ''; + + parent::__construct($message, $code, $previous); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function getDebug(): string + { + return $this->debug; + } +} diff --git a/src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php b/src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php new file mode 100644 index 0000000000000..00fcb6a1ce049 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 5.1 + */ +interface TransportExceptionInterface extends ExceptionInterface +{ + public function getDebug(): string; +} diff --git a/src/Symfony/Component/Translation/Loader/XliffRawLoader.php b/src/Symfony/Component/Translation/Loader/XliffRawLoader.php new file mode 100644 index 0000000000000..ee0d51cda427e --- /dev/null +++ b/src/Symfony/Component/Translation/Loader/XliffRawLoader.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Loader; + +use Symfony\Component\Config\Util\Exception\InvalidXmlException; +use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\Translation\Exception\InvalidResourceException; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Util\XliffUtils; + +/** + * XliffRawLoader loads translations from XLIFF string. + * + * @author Fabien Potencier + */ +class XliffRawLoader implements LoaderInterface +{ + /** + * {@inheritdoc} + */ + public function load($resource, string $locale, string $domain = 'messages') + { + try { + $dom = XmlUtils::parse($resource); + } catch (InvalidXmlException $exception) { + throw new InvalidResourceException(sprintf('This is not a XLIFF string "%s".', $resource)); + } + + $catalogue = new MessageCatalogue($locale); + $this->extract($dom, $catalogue, $domain); + + return $catalogue; + } + + private function extract($resource, MessageCatalogue $catalogue, string $domain) + { + $xliffVersion = XliffUtils::getVersionNumber($resource); + if ($errors = XliffUtils::validateSchema($resource)) { + throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: '.XliffUtils::getErrorsAsString($errors), $resource)); + } + + if ('1.2' === $xliffVersion) { + $this->extractXliff1($resource, $catalogue, $domain); + } + + if ('2.0' === $xliffVersion) { + $this->extractXliff2($resource, $catalogue, $domain); + } + } + + /** + * Extract messages and metadata from DOMDocument into a MessageCatalogue. + */ + private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain) + { + $xml = simplexml_import_dom($dom); + $encoding = strtoupper($dom->encoding); + + $namespace = 'urn:oasis:names:tc:xliff:document:1.2'; + $xml->registerXPathNamespace('xliff', $namespace); + + foreach ($xml->xpath('//xliff:file') as $file) { + $fileAttributes = $file->attributes(); + + $file->registerXPathNamespace('xliff', $namespace); + + foreach ($file->xpath('.//xliff:trans-unit') as $translation) { + $attributes = $translation->attributes(); + + if (!(isset($attributes['resname']) || isset($translation->source))) { + continue; + } + + $source = isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source; + // If the xlf file has another encoding specified, try to convert it because + // simple_xml will always return utf-8 encoded values + $target = $this->utf8ToCharset((string) ($translation->target ?? $translation->source), $encoding); + + $catalogue->set((string) $source, $target, $domain); + + $metadata = [ + 'source' => (string) $translation->source, + 'file' => [ + 'original' => (string) $fileAttributes['original'], + ], + ]; + if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) { + $metadata['notes'] = $notes; + } + + if (isset($translation->target) && $translation->target->attributes()) { + $metadata['target-attributes'] = []; + foreach ($translation->target->attributes() as $key => $value) { + $metadata['target-attributes'][$key] = (string) $value; + } + } + + if (isset($attributes['id'])) { + $metadata['id'] = (string) $attributes['id']; + } + + $catalogue->setMetadata((string) $source, $metadata, $domain); + } + } + } + + private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain) + { + $xml = simplexml_import_dom($dom); + $encoding = strtoupper($dom->encoding); + + $xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0'); + + foreach ($xml->xpath('//xliff:unit') as $unit) { + foreach ($unit->segment as $segment) { + $attributes = $unit->attributes(); + $source = $attributes['name'] ?? $segment->source; + + // If the xlf file has another encoding specified, try to convert it because + // simple_xml will always return utf-8 encoded values + $target = $this->utf8ToCharset((string) ($segment->target ?? $segment->source), $encoding); + + $catalogue->set((string) $source, $target, $domain); + + $metadata = []; + if (isset($segment->target) && $segment->target->attributes()) { + $metadata['target-attributes'] = []; + foreach ($segment->target->attributes() as $key => $value) { + $metadata['target-attributes'][$key] = (string) $value; + } + } + + if (isset($unit->notes)) { + $metadata['notes'] = []; + foreach ($unit->notes->note as $noteNode) { + $note = []; + foreach ($noteNode->attributes() as $key => $value) { + $note[$key] = (string) $value; + } + $note['content'] = (string) $noteNode; + $metadata['notes'][] = $note; + } + } + + $catalogue->setMetadata((string) $source, $metadata, $domain); + } + } + } + + /** + * Convert a UTF8 string to the specified encoding. + */ + private function utf8ToCharset(string $content, string $encoding = null): string + { + if ('UTF-8' !== $encoding && !empty($encoding)) { + return mb_convert_encoding($content, $encoding, 'UTF-8'); + } + + return $content; + } + + private function parseNotesMetadata(\SimpleXMLElement $noteElement = null, string $encoding = null): array + { + $notes = []; + + if (null === $noteElement) { + return $notes; + } + + /** @var \SimpleXMLElement $xmlNote */ + foreach ($noteElement as $xmlNote) { + $noteAttributes = $xmlNote->attributes(); + $note = ['content' => $this->utf8ToCharset((string) $xmlNote, $encoding)]; + if (isset($noteAttributes['priority'])) { + $note['priority'] = (int) $noteAttributes['priority']; + } + + if (isset($noteAttributes['from'])) { + $note['from'] = (string) $noteAttributes['from']; + } + + $notes[] = $note; + } + + return $notes; + } +} diff --git a/src/Symfony/Component/Translation/Reader/TranslationReader.php b/src/Symfony/Component/Translation/Reader/TranslationReader.php index 9e51b15b59826..93662e3e25a0f 100644 --- a/src/Symfony/Component/Translation/Reader/TranslationReader.php +++ b/src/Symfony/Component/Translation/Reader/TranslationReader.php @@ -48,6 +48,9 @@ public function read(string $directory, MessageCatalogue $catalogue) return; } + /** + * @var LoaderInterface $loader + */ foreach ($this->loaders as $format => $loader) { // load any existing translation files $finder = new Finder(); diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemote.php b/src/Symfony/Component/Translation/Remote/AbstractRemote.php new file mode 100644 index 0000000000000..75d4b2667ec00 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/AbstractRemote.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\Remote; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Translation\Event\MessageEvent; +use Symfony\Component\Translation\Exception\LogicException; +use Symfony\Component\Translation\Message\MessageInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +abstract class AbstractRemote implements RemoteInterface +{ + protected const HOST = 'localhost'; + + protected $client; + protected $host; + protected $port; + + public function __construct(HttpClientInterface $client = null) + { + $this->client = $client; + if (null === $client) { + if (!class_exists(HttpClient::class)) { + throw new LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); + } + + $this->client = HttpClient::create(); + } + } + + /** + * @return $this + */ + public function setHost(?string $host): self + { + $this->host = $host; + + return $this; + } + + /** + * @return $this + */ + public function setPort(?int $port): self + { + $this->port = $port; + + return $this; + } + + protected function getEndpoint(): ?string + { + return ($this->host ?: $this->getDefaultHost()).($this->port ? ':'.$this->port : ''); + } + + protected function getDefaultHost(): string + { + return static::HOST; + } +} diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php new file mode 100644 index 0000000000000..9cb31f1b2c799 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.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\Remote; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +abstract class AbstractRemoteFactory implements RemoteFactoryInterface +{ + protected $client; + protected $loader; + + public function __construct(HttpClientInterface $client = null, LoaderInterface $loader) + { + $this->client = $client; + $this->loader = $loader; + } + + public function supports(Dsn $dsn): bool + { + return \in_array($dsn->getScheme(), $this->getSupportedSchemes()); + } + + /** + * @return string[] + */ + abstract protected function getSupportedSchemes(): array; + + protected function getUser(Dsn $dsn): string + { + $user = $dsn->getUser(); + if (null === $user) { + throw new IncompleteDsnException('User is not set.', $dsn->getOriginalDsn()); + } + + return $user; + } + + protected function getPassword(Dsn $dsn): string + { + $password = $dsn->getPassword(); + if (null === $password) { + throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn()); + } + + return $password; + } +} diff --git a/src/Symfony/Component/Translation/Remote/Dsn.php b/src/Symfony/Component/Translation/Remote/Dsn.php new file mode 100644 index 0000000000000..0977f68156101 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/Dsn.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * + * @experimental in 5.1 + */ +final class Dsn +{ + private $scheme; + private $host; + private $user; + private $password; + private $port; + private $options; + private $path; + private $dsn; + + public function __construct(string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = [], ?string $path = null) + { + $this->scheme = $scheme; + $this->host = $host; + $this->user = $user; + $this->password = $password; + $this->port = $port; + $this->options = $options; + $this->path = $path; + } + + public static function fromString(string $dsn): self + { + if (false === $parsedDsn = parse_url($dsn)) { + throw new InvalidArgumentException(sprintf('The "%s" translation DSN is invalid.', $dsn)); + } + + if (!isset($parsedDsn['scheme'])) { + throw new InvalidArgumentException(sprintf('The "%s" translation DSN must contain a scheme.', $dsn)); + } + + if (!isset($parsedDsn['host'])) { + throw new InvalidArgumentException(sprintf('The "%s" translation DSN must contain a host (use "default" by default).', $dsn)); + } + + $user = isset($parsedDsn['user']) ? urldecode($parsedDsn['user']) : null; + $password = isset($parsedDsn['pass']) ? urldecode($parsedDsn['pass']) : null; + $port = $parsedDsn['port'] ?? null; + $path = $parsedDsn['path'] ?? null; + parse_str($parsedDsn['query'] ?? '', $query); + + $dsnObject = new self($parsedDsn['scheme'], $parsedDsn['host'], $user, $password, $port, $query, $path); + $dsnObject->dsn = $dsn; + + return $dsnObject; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getPort(int $default = null): ?int + { + return $this->port ?? $default; + } + + public function getOption(string $key, $default = null) + { + return $this->options[$key] ?? $default; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function getOriginalDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Symfony/Component/Translation/Remote/NullRemote.php b/src/Symfony/Component/Translation/Remote/NullRemote.php new file mode 100644 index 0000000000000..6821cf74432ed --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/NullRemote.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\Translation\Event\MessageEvent; +use Symfony\Component\Translation\Message\MessageInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +class NullRemote implements RemoteInterface +{ + private $dispatcher; + + public function __construct(EventDispatcherInterface $dispatcher = null) + { + $this->dispatcher = class_exists(Event::class) ? LegacyEventDispatcherProxy::decorate($dispatcher) : $dispatcher; + } + + public function send(MessageInterface $message): void + { + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(new MessageEvent($message)); + } + } + + public function __toString(): string + { + return 'null'; + } +} diff --git a/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php b/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php new file mode 100644 index 0000000000000..52fac729a2263 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; + +final class NullRemoteFactory extends AbstractRemoteFactory +{ + /** + * @return NullRemote + */ + public function create(Dsn $dsn): RemoteInterface + { + if ('null' === $dsn->getScheme()) { + return new NullRemote($this->dispatcher); + } + + throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['null']; + } +} diff --git a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php new file mode 100644 index 0000000000000..66b782fd46c50 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\Translation\TranslatorBag; + +class RemoteDecorator implements RemoteInterface +{ + private $remote; + private $locales; + private $domains; + + public function __construct(RemoteInterface $remote, array $locales, array $domains = []) + { + $this->remote = $remote; + $this->locales = $locales; + $this->domains = $domains; + } + + /** + * {@inheritdoc} + */ + public function write(TranslatorBag $translations): void + { + $this->remote->write($translations); + } + + /** + * {@inheritdoc} + */ + public function read(array $domains, array $locales): TranslatorBag + { + $domains = empty($this->domains) ? $domains : array_intersect($this->domains, $domains); + $locales = array_intersect($this->locales, $locales); + + return $this->remote->read($domains, $locales); + } + + /** + * {@inheritdoc} + */ + public function delete(TranslatorBag $translations): void + { + $this->remote->delete($translations); + } +} diff --git a/src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php b/src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php new file mode 100644 index 0000000000000..9edfc0465a980 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; + +interface RemoteFactoryInterface +{ + /** + * @throws UnsupportedSchemeException + * @throws IncompleteDsnException + */ + public function create(Dsn $dsn): RemoteInterface; + + public function supports(Dsn $dsn): bool; +} diff --git a/src/Symfony/Component/Translation/Remote/RemoteInterface.php b/src/Symfony/Component/Translation/Remote/RemoteInterface.php new file mode 100644 index 0000000000000..c6fe60fab7328 --- /dev/null +++ b/src/Symfony/Component/Translation/Remote/RemoteInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Remote; + +use Symfony\Component\Translation\TranslatorBag; + +/** + * Remote is used to sync translations with a remote. + */ +interface RemoteInterface +{ + /** + * Write given translation to the remote. + * + * * Translations available in the MessageCatalogue only must be created. + * * Translations available in bot the MessageCatalogue and on the remote + * must be overwriten. + * * Translations available on the remote only must be kept. + */ + public function write(TranslatorBag $translations): void; + + /** + * This method must return asked translations. + */ + public function read(array $domains, array $locales): TranslatorBag; + + /** + * This method must delete all translation given in the TranslatorBag. + */ + public function delete(TranslatorBag $translations): void; +} diff --git a/src/Symfony/Component/Translation/Remotes.php b/src/Symfony/Component/Translation/Remotes.php new file mode 100644 index 0000000000000..fc568597149ae --- /dev/null +++ b/src/Symfony/Component/Translation/Remotes.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; +use Symfony\Component\Translation\Remote\RemoteInterface; + +final class Remotes +{ + private $remotes; + + /** + * @param RemoteInterface[] $remotes + */ + public function __construct(iterable $remotes) + { + $this->remotes = []; + foreach ($remotes as $name => $remote) { + $this->remotes[$name] = $remote; + } + } + + public function __toString(): string + { + return '['.implode(',', array_keys($this->remotes)).']'; + } + + public function has(string $name): bool + { + return isset($this->remotes[$name]); + } + + public function get(string $name): RemoteInterface + { + if (!$this->has($name)) { + throw new InvalidArgumentException(sprintf('Remote "%s" not found. Available: %s', $name, (string) $this)); + } + + return $this->remotes[$name]; + } + + public function keys(): array + { + return array_keys($this->remotes); + } +} diff --git a/src/Symfony/Component/Translation/RemotesFactory.php b/src/Symfony/Component/Translation/RemotesFactory.php new file mode 100644 index 0000000000000..12b865a725ea6 --- /dev/null +++ b/src/Symfony/Component/Translation/RemotesFactory.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Bridge\Firebase\FirebaseRemoteFactory; +use Symfony\Component\Translation\Bridge\FreeMobile\FreeMobileRemoteFactory; +use Symfony\Component\Translation\Bridge\Mattermost\MattermostRemoteFactory; +use Symfony\Component\Translation\Bridge\Nexmo\NexmoRemoteFactory; +use Symfony\Component\Translation\Bridge\OvhCloud\OvhCloudRemoteFactory; +use Symfony\Component\Translation\Bridge\RocketChat\RocketChatRemoteFactory; +use Symfony\Component\Translation\Bridge\Sinch\SinchRemoteFactory; +use Symfony\Component\Translation\Bridge\Slack\SlackRemoteFactory; +use Symfony\Component\Translation\Bridge\Telegram\TelegramRemoteFactory; +use Symfony\Component\Translation\Bridge\Twilio\TwilioRemoteFactory; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Remote\Dsn; +use Symfony\Component\Translation\Remote\FailoverRemote; +use Symfony\Component\Translation\Remote\NullRemoteFactory; +use Symfony\Component\Translation\Remote\RoundRobinRemote; +use Symfony\Component\Translation\Remote\RemoteDecorator; +use Symfony\Component\Translation\Remote\RemoteFactoryInterface; +use Symfony\Component\Translation\Remote\RemoteInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class RemotesFactory +{ + private const FACTORY_CLASSES = [ + LocoRemoteFactory::class, + ]; + + private $factories; + private $enabledLocales; + + /** + * @param RemoteFactoryInterface[] $factories + */ + public function __construct(iterable $factories, array $enabledLocales) + { + $this->factories = $factories; + $this->enabledLocales = $enabledLocales; + } + + public function fromConfig(array $config): Remotes + { + $remotes = []; + foreach ($config as $name => $currentConfig) { + $remotes[$name] = $this->fromString( + $currentConfig['dsn'], + empty($currentConfig['locales']) ? $this->enabledLocales : $currentConfig['locales'], + empty($currentConfig['domains']) ? [] : $currentConfig['domains'] + ); + } + + return new Remotes($remotes); + } + + public function fromString(string $dsn, array $locales, array $domains = []): RemoteInterface + { + return $this->fromDsnObject(Dsn::fromString($dsn), $locales, $domains); + } + + public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): RemoteInterface + { + foreach ($this->factories as $factory) { + if ($factory->supports($dsn)) { + return new RemoteDecorator($factory->create($dsn), $locales, $domains); + } + } + + throw new UnsupportedSchemeException($dsn); + } + + /** + * @return RemoteFactoryInterface[] + */ + private static function getDefaultFactories(HttpClientInterface $client = null): iterable + { + foreach (self::FACTORY_CLASSES as $factoryClass) { + if (class_exists($factoryClass)) { + yield new $factoryClass($client); + } + } + + yield new NullRemoteFactory($client); + } +} diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php new file mode 100644 index 0000000000000..96c67f0f39ed7 --- /dev/null +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +final class TranslatorBag +{ + private $catalogues = []; + + public function addCatalogue(MessageCatalogue $catalogue): void + { + $this->catalogues[] = $catalogue; + } + + public function getDomains(): array + { + $domains = []; + + foreach ($this->catalogues as $catalogue) { + $domains += $catalogue->getDomains(); + } + + return array_unique($domains); + } + + public function all(): array + { + $messages = []; + + foreach ($this->catalogues as $catalogue) { + $locale = $catalogue->getLocale(); + if (!isset($messages[$locale])) { + $messages[$locale] = $catalogue->all(); + } else { + $messages[$locale] = array_merge($messages[$locale], $catalogue->all()); + } + } + + return $messages; + } +} From ec119b41b07e6e4781130c5e981d678f6b9cfc92 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Sun, 10 May 2020 16:48:56 +0200 Subject: [PATCH 02/14] Loco API Client --- .../Command/TranslationPullCommand.php | 48 ++++++- .../Command/TranslationPushCommand.php | 64 ++++++--- .../Resources/config/translation_remotes.xml | 1 + .../Translation/Bridge/Loco/LocoRemote.php | 131 +++++++++++++++++- .../Bridge/Loco/LocoRemoteFactory.php | 2 +- .../Remote/AbstractRemoteFactory.php | 4 +- .../Translation/Remote/NullRemote.php | 16 +++ .../Translation/Remote/RemoteDecorator.php | 4 +- .../Translation/Remote/RemoteInterface.php | 6 +- .../Component/Translation/TranslatorBag.php | 14 +- .../Translation/Writer/TranslationWriter.php | 3 +- 11 files changed, 255 insertions(+), 38 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index 3f9cf7c38e3ac..e33e0c8df47c8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -78,7 +78,7 @@ protected function configure() ->setDescription('Pull translations from a given remote.') ->setHelp(<<<'EOF' The %command.name% pull translations from the given remote. Only -new translations are pulled, existing ones are not overwriten. +new translations are pulled, existing ones are not overwritten. You can overwrite existing translations: @@ -86,15 +86,15 @@ protected function configure() You can remote local translations which are not present on the remote: - php %command.full_name% --delete-absolete remote + php %command.full_name% --delete-obsolete remote Full example: - php %command.full_name% remote --force --delete-obslete --domains=messages,validators --locales=en + php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en This command will pull all translations linked to domains messages & validators for the locale en. Local translations for the specified domains & locale will -be erased if they're not present on the remote and overwriten if it's the +be erased if they're not present on the remote and overwritten if it's the case. Local translations for others domains & locales will be ignored. EOF ) @@ -106,7 +106,44 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output): int { - $remoteTranslations = $this->remotes->get($input->getArgument('remote'))->read(); + $remoteStorage = $this->remotes->get($input->getArgument('remote')); + + $locales = $input->getOption('locales'); + $domains = $input->getOption('domains'); + $force = $input->getOption('force'); + $deleteObsolete = $input->getOption('delete-obsolete'); + + $remoteTranslations = $remoteStorage->read($domains, $locales); + + if ($deleteObsolete && $force) { + foreach ($locales as $locale) { + $options = []; + + if ($input->getOption('xliff-version')) { + $options['xliff_version'] = $input->getOption('xliff-version'); + } + + $this->writer->write($remoteTranslations->getCatalogue($locale), $input->getOption('output-format'), $options); + } + + return 0; + } + + if ($force) { + // merge all messages from remote to local ones + + return 0; + } else { + // merge only new messages from remote to local ones + + return 0; + } + + if ($deleteObsolete) { + // remove diff between local messages and remote ones + + return 0; + } //$this->writer->write($operation->getResult(), $input->getOption('output-format'), [ //'path' => $bundleTransPath, @@ -114,7 +151,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int //'xliff_version' => $input->getOption('xliff-version') //]); - dump($this->remotes); return 0; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index f3957845ba328..ed8c36bd872f6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -22,6 +22,7 @@ use Symfony\Component\Translation\Catalogue\MergeOperation; use Symfony\Component\Translation\Catalogue\TargetOperation; use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; @@ -41,6 +42,7 @@ class TranslationPushCommand extends Command private $defaultTransPath; private $transPaths; private $enabledLocales; + private $arrayLoader; public function __construct(Remotes $remotes, TranslationReaderInterface $reader, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) { @@ -49,6 +51,7 @@ public function __construct(Remotes $remotes, TranslationReaderInterface $reader $this->defaultTransPath = $defaultTransPath; $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; + $this->arrayLoader = new ArrayLoader(); parent::__construct(); } @@ -74,7 +77,7 @@ protected function configure() ->setDescription('Push translations to a given remote.') ->setHelp(<<<'EOF' The %command.name% push translations to the given remote. Only new -translations are pushed, existing ones are not overwriten. +translations are pushed, existing ones are not overwritten. You can overwrite existing translations: @@ -82,15 +85,15 @@ protected function configure() You can delete remote translations which are not present locally: - php %command.full_name% --delete-absolete remote + php %command.full_name% --delete-obsolete remote Full example: - php %command.full_name% remote --force --delete-obslete --domains=messages,validators --locales=en + php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en This command will push all translations linked to domains messages & validators for the locale en. Remote translations for the specified domains & locale will -be erased if they're not present locally and overwriten if it's the +be erased if they're not present locally and overwritten if it's the case. Remote translations for others domains & locales will be ignored. EOF ) @@ -106,35 +109,62 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new InvalidArgumentException('You must defined framework.translator.enabled_locales config key in order to work with remotes.'); } - $locales = $input->getOption('locales'); - $domains = $input->getOption('domains'); + $io = new SymfonyStyle($input, $output); - /** @var KernelInterface $kernel */ - $kernel = $this->getApplication()->getKernel(); + $remoteStorage = $this->remotes->get($input->getArgument('remote')); + + $locales = $input->getOption('locales'); + $force = $input->getOption('force'); + $deleteObsolete = $input->getOption('delete-obsolete'); $transPaths = $this->transPaths; if ($this->defaultTransPath) { $transPaths[] = $this->defaultTransPath; } + /** @var KernelInterface $kernel */ + $kernel = $this->getApplication()->getKernel(); + // Override with provided Bundle info foreach ($kernel->getBundles() as $bundle) { $bundleDir = $bundle->getPath(); $transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundle->getPath().'/translations'; } - $translatorBag = new TranslatorBag(); + $localTranslations = new TranslatorBag(); foreach ($locales as $locale) { - $translatorBag->addCatalogue($this->loadCurrentMessages($locale, $transPaths)); + $localTranslations->addCatalogue($this->loadCurrentMessages($locale, $transPaths)); } - $remoteTranslations = $this->remotes - ->get($input->getArgument('remote')) - ->read($domains ?? $translatorBag->getDomains(), $locales); + $domains = $input->getOption('domains') ?: $localTranslations->getDomains(); + + $remoteTranslations = $remoteStorage->read($domains, $locales); + foreach ($locales as $locale) { + $remoteCatalogue = $remoteTranslations->getCatalogue($locale); + $localCatalogue = $localTranslations->getCatalogue($locale); + + $operation = new TargetOperation($remoteCatalogue, $localCatalogue); + foreach ($domains as $domain) { + if ($force) { + $messages = $operation->getMessages($domain); + } else { + $messages = $operation->getNewMessages($domain); + } + + $bag = new TranslatorBag(); + $bag->addCatalogue($this->arrayLoader->load($messages, $locale, $domain)); + $remoteStorage->write($bag); + + if ($deleteObsolete) { + $obsoleteMessages = $operation->getObsoleteMessages($domain); + $bag = new TranslatorBag(); + $bag->addCatalogue($this->arrayLoader->load($obsoleteMessages, $locale, $domain)); + $remoteStorage->delete($bag); + } + } + } - // diff between $remoteTranslations and $localTranslations, - // then write to remote the diff (aka. new translation not yet in the remote storage) return 0; } @@ -142,9 +172,7 @@ private function loadCurrentMessages(string $locale, array $transPaths): Message { $currentCatalogue = new MessageCatalogue($locale); foreach ($transPaths as $path) { - if (is_dir($path)) { - $this->reader->read($path, $currentCatalogue); - } + $this->reader->read($path, $currentCatalogue); } return $currentCatalogue; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml index cc9dc1d9ea51e..a5c59b3e5d33c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml @@ -8,6 +8,7 @@ + %kernel.default_locale% diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php index 0feb03db8fdc3..45a95f45cbe6a 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -15,7 +15,6 @@ use Symfony\Component\Translation\Exception\RemoteException; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\Loader\XliffRawLoader; use Symfony\Component\Translation\Message\MessageInterface; use Symfony\Component\Translation\Message\SmsMessage; use Symfony\Component\Translation\Remote\AbstractRemote; @@ -26,6 +25,11 @@ * @author Fabien Potencier * * @experimental in 5.1 + * + * In Loco: + * tags refers to Symfony's translation domains + * assets refers to Symfony's translation keys + * translations refers to Symfony's translation messages */ final class LocoRemote extends AbstractRemote { @@ -33,11 +37,13 @@ final class LocoRemote extends AbstractRemote private $apiKey; private $loader; + private $defaultLocale; - public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader) + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) { $this->apiKey = $apiKey; $this->loader = $loader; + $this->defaultLocale = $defaultLocale; parent::__construct($client); } @@ -50,8 +56,21 @@ public function __toString(): string /** * {@inheritdoc} */ - public function write(TranslatorBag $translations): void + public function write(TranslatorBag $translations, bool $override = false): void { + foreach ($translations->all() as $locale => $messages) { + foreach ($messages as $domain => $messages) { + $ids = []; + + foreach ($messages as $id => $message) { + $ids[] = $id; + $this->createAsset($id); + $this->translateAsset($id, $message, $locale); + } + + $this->tagsAssets($ids, $domain); + } + } } /** @@ -99,5 +118,111 @@ public function read(array $domains, array $locales): TranslatorBag */ public function delete(TranslatorBag $translations): void { + foreach ($translations->all() as $locale => $messages) { + foreach ($messages as $domain => $messages) { + foreach ($messages as $id => $message) { + $this->deleteAsset($id); + } + } + } + } + + private function createAsset(string $id) + { + $response = $this->client->request('POST', sprintf('https://%s/api/assets', $this->getEndpoint()), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + 'body' => [ + 'name' => $id, + 'id' => $id, + 'type' => 'text', + 'default' => 'untranslated', + ] + ]); + + if ($response->getStatusCode() === Response::HTTP_CONFLICT) { + // Translation key already exists in Loco, do nothing + } elseif ($response->getStatusCode() !== Response::HTTP_CREATED || $response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: %s', $id, $response->getContent(false)), $response); + } + } + + private function translateAsset(string $id, string $message, string $locale) + { + $response = $this->client->request('POST', sprintf('https://%s/api/translations/%s/%s', $this->getEndpoint(), $id, $locale), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + 'body' => $message, + ]); + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to add translation message (for key: %s) to Loco: %s', $id, $response->getContent(false)), $response); + } + } + + private function tagsAssets(array $ids, string $tag) + { + $idsAsString = implode(',', array_unique($ids)); + + if (!\in_array($tag, $this->getTags())) { + $this->createTag($tag); + } + + $response = $this->client->request('POST', sprintf('https://%s/api/tags/%s.json', $this->getEndpoint(), $tag), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + 'body' => $idsAsString, + ]); + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to add tag (%s) on translation keys (%s) to Loco: %s', $tag, $idsAsString, $response->getContent(false)), $response); + } + } + + private function createTag(string $tag) + { + $response = $this->client->request('POST', sprintf('https://%s/api/tags.json', $this->getEndpoint(), $tag), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + 'name' => $tag, + ]); + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to create tag (%s) on Loco: %s', $tag, $response->getContent(false)), $response); + } + } + + private function getTags(): array + { + $response = $this->client->request('GET', sprintf('https://%s/api/tags.json', $this->getEndpoint()), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ], + ]); + + $content = $response->getContent(); + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to get tags on Loco: %s', $response->getContent(false)), $response); + } + + return json_decode($content); + } + + private function deleteAsset(string $id) + { + $response = $this->client->request('DELETE', sprintf('https://%s/api/assets/%s.json', $this->getEndpoint(), $id), [ + 'headers' => [ + 'Authorization' => 'Loco '.$this->apiKey, + ] + ]); + + if ($response->getStatusCode() !== Response::HTTP_OK) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: %s', $id, $response->getContent(false)), $response); + } } } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php index 6eed45126f88c..491acd56cc7b1 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php @@ -30,7 +30,7 @@ public function create(Dsn $dsn): RemoteInterface $port = $dsn->getPort(); if ('loco' === $scheme) { - return (new LocoRemote($apiKey, $this->client, $this->loader)) + return (new LocoRemote($apiKey, $this->client, $this->loader, $this->defaultLocale)) ->setHost($host) ->setPort($port) ; diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php index 9cb31f1b2c799..3f6fd6fa9d15a 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php +++ b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php @@ -22,11 +22,13 @@ abstract class AbstractRemoteFactory implements RemoteFactoryInterface { protected $client; protected $loader; + protected $defaultLocale; - public function __construct(HttpClientInterface $client = null, LoaderInterface $loader) + public function __construct(HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) { $this->client = $client; $this->loader = $loader; + $this->defaultLocale = $defaultLocale; } public function supports(Dsn $dsn): bool diff --git a/src/Symfony/Component/Translation/Remote/NullRemote.php b/src/Symfony/Component/Translation/Remote/NullRemote.php index 6821cf74432ed..1940097cba4e1 100644 --- a/src/Symfony/Component/Translation/Remote/NullRemote.php +++ b/src/Symfony/Component/Translation/Remote/NullRemote.php @@ -15,6 +15,7 @@ use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; use Symfony\Component\Translation\Event\MessageEvent; use Symfony\Component\Translation\Message\MessageInterface; +use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class NullRemote implements RemoteInterface @@ -37,4 +38,19 @@ public function __toString(): string { return 'null'; } + + public function write(TranslatorBag $translations, bool $override = false): void + { + // TODO: Implement write() method. + } + + public function read(array $domains, array $locales): TranslatorBag + { + // TODO: Implement read() method. + } + + public function delete(TranslatorBag $translations): void + { + // TODO: Implement delete() method. + } } diff --git a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php index 66b782fd46c50..20ea1a15bb33f 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php +++ b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php @@ -29,9 +29,9 @@ public function __construct(RemoteInterface $remote, array $locales, array $doma /** * {@inheritdoc} */ - public function write(TranslatorBag $translations): void + public function write(TranslatorBag $translations, bool $override = false): void { - $this->remote->write($translations); + $this->remote->write($translations, $override); } /** diff --git a/src/Symfony/Component/Translation/Remote/RemoteInterface.php b/src/Symfony/Component/Translation/Remote/RemoteInterface.php index c6fe60fab7328..043dbcfd7e021 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteInterface.php +++ b/src/Symfony/Component/Translation/Remote/RemoteInterface.php @@ -22,11 +22,11 @@ interface RemoteInterface * Write given translation to the remote. * * * Translations available in the MessageCatalogue only must be created. - * * Translations available in bot the MessageCatalogue and on the remote - * must be overwriten. + * * Translations available in both the MessageCatalogue and on the remote + * must be overwritten. * * Translations available on the remote only must be kept. */ - public function write(TranslatorBag $translations): void; + public function write(TranslatorBag $translations, bool $override = false): void; /** * This method must return asked translations. diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php index 96c67f0f39ed7..b0433f00d568c 100644 --- a/src/Symfony/Component/Translation/TranslatorBag.php +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -11,13 +11,14 @@ namespace Symfony\Component\Translation; -final class TranslatorBag +final class TranslatorBag implements TranslatorBagInterface { + /** @var MessageCatalogue[] */ private $catalogues = []; public function addCatalogue(MessageCatalogue $catalogue): void { - $this->catalogues[] = $catalogue; + $this->catalogues[$catalogue->getLocale()] = $catalogue; } public function getDomains(): array @@ -46,4 +47,13 @@ public function all(): array return $messages; } + + public function getCatalogue(string $locale = null): ?MessageCatalogue + { + if (!$locale) { + return null; + } + + return $this->catalogues[$locale]; + } } diff --git a/src/Symfony/Component/Translation/Writer/TranslationWriter.php b/src/Symfony/Component/Translation/Writer/TranslationWriter.php index e0260b7a30593..1b5f18317f231 100644 --- a/src/Symfony/Component/Translation/Writer/TranslationWriter.php +++ b/src/Symfony/Component/Translation/Writer/TranslationWriter.php @@ -23,6 +23,7 @@ */ class TranslationWriter implements TranslationWriterInterface { + /** @var DumperInterface[] */ private $dumpers = []; /** @@ -59,14 +60,12 @@ public function write(MessageCatalogue $catalogue, string $format, array $option throw new InvalidArgumentException(sprintf('There is no dumper associated with format "%s".', $format)); } - // get the right dumper $dumper = $this->dumpers[$format]; if (isset($options['path']) && !is_dir($options['path']) && !@mkdir($options['path'], 0777, true) && !is_dir($options['path'])) { throw new RuntimeException(sprintf('Translation Writer was not able to create directory "%s".', $options['path'])); } - // save $dumper->dump($catalogue, $options); } } From 89ea04c616d2c877f72ef588b0606ad6005a7ec1 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Mon, 29 Jun 2020 12:53:32 +0200 Subject: [PATCH 03/14] Move all configuration from XML to PHP --- .../FrameworkExtension.php | 2 +- .../Resources/config/console.php | 25 +++++++++++++++ .../Resources/config/translation.php | 18 +++++++++++ .../Resources/config/translation_remotes.php | 32 +++++++++++++++++++ .../Resources/config/translation_remotes.xml | 18 ----------- 5 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 7e83a6aafb376..1957dfbf033e1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1142,7 +1142,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder } $loader->load('translation.php'); - $loader->load('translation_remotes.xml'); + $loader->load('translation_remotes.php'); // Use the "real" translator instead of the identity default $container->setAlias('translator', 'translator.default')->setPublic(true); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index e9b3d2e36a855..37511d2a9e660 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -34,6 +34,8 @@ use Symfony\Bundle\FrameworkBundle\Command\SecretsRemoveCommand; use Symfony\Bundle\FrameworkBundle\Command\SecretsSetCommand; use Symfony\Bundle\FrameworkBundle\Command\TranslationDebugCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationPullCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationPushCommand; use Symfony\Bundle\FrameworkBundle\Command\TranslationUpdateCommand; use Symfony\Bundle\FrameworkBundle\Command\WorkflowDumpCommand; use Symfony\Bundle\FrameworkBundle\Command\YamlLintCommand; @@ -232,6 +234,29 @@ ]) ->tag('console.command', ['command' => 'debug:validator']) + ->set('console.command.translation_pull', TranslationPullCommand::class) + ->args([ + service('translation.remotes'), + service('translation.writer'), + service('translation.reader'), + param('kernel.default_locale'), + param('translator.default_path'), + [], // Translator paths + [], // Enabled locales + ]) + ->tag('console.command', ['command' => 'translation:pull']) + + ->set('console.command.translation_push', TranslationPushCommand::class) + ->args([ + service('translation.remotes'), + service('translation.reader'), + param('kernel.default_locale'), + param('translator.default_path'), + [], // Translator paths + [], // Enabled locales + ]) + ->tag('console.command', ['command' => 'translation:push']) + ->set('console.command.workflow_dump', WorkflowDumpCommand::class) ->tag('console.command', ['command' => 'workflow:dump']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index 706e4928ee2e0..cfd1b339af476 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -38,10 +38,13 @@ use Symfony\Component\Translation\Loader\PoFileLoader; use Symfony\Component\Translation\Loader\QtFileLoader; use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Loader\XliffRawLoader; use Symfony\Component\Translation\Loader\YamlFileLoader; use Symfony\Component\Translation\LoggingTranslator; use Symfony\Component\Translation\Reader\TranslationReader; use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Remotes; +use Symfony\Component\Translation\RemotesFactory; use Symfony\Component\Translation\Writer\TranslationWriter; use Symfony\Component\Translation\Writer\TranslationWriterInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -84,6 +87,9 @@ ->set('translation.loader.xliff', XliffFileLoader::class) ->tag('translation.loader', ['alias' => 'xlf', 'legacy-alias' => 'xliff']) + ->set('translation.loader.xliff_raw', XliffRawLoader::class) + ->tag('translation.loader', ['alias' => 'xlf_raw']) + ->set('translation.loader.po', PoFileLoader::class) ->tag('translation.loader', ['alias' => 'po']) @@ -158,5 +164,17 @@ ->args([service(ContainerInterface::class)]) ->tag('container.service_subscriber', ['id' => 'translator']) ->tag('kernel.cache_warmer') + + ->set('translation.remotes', Remotes::class) + ->factory([service('translation.remotes_factory'), 'fromConfig']) + ->args([ + [], // transports + ]) + + ->set('translation.remotes_factory', RemotesFactory::class) + ->args([ + tagged_iterator('translation.remote_factory'), + [], // Enabled locales + ]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php new file mode 100644 index 0000000000000..7eb9cf4624576 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; +use Symfony\Component\Translation\Remote\AbstractRemoteFactory; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('translation.remote_factory.abstract', AbstractRemoteFactory::class) + ->args([ + service('http_client')->ignoreOnInvalid(), + service('translation.loader.xliff_raw'), + param('kernel.default_locale') + ]) + ->abstract() + + ->set('translation.remote_factory.loco', LocoRemoteFactory::class) + ->args([service('translator.data_collector')]) + ->parent('translation.remote_factory.abstract') + ->tag('translation.remote_factory') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml deleted file mode 100644 index a5c59b3e5d33c..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - %kernel.default_locale% - - - - - - - From 1653289fb86e0aa3d9e118b2e91203397e9aa614 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Tue, 30 Jun 2020 10:48:02 +0200 Subject: [PATCH 04/14] First working state push and pull commands --- .../Command/TranslationPullCommand.php | 103 ++++++----- .../Command/TranslationPushCommand.php | 114 +++++------- .../Command/TranslationTrait.php | 71 +++++++ .../Command/TranslationUpdateCommand.php | 49 +---- .../Compiler/UnusedTagsPass.php | 1 + .../FrameworkExtension.php | 39 +++- .../Resources/config/console.php | 1 - .../Resources/config/translation.php | 2 +- .../Resources/config/translation_remotes.php | 6 +- .../Command/TranslationPullCommandTest.php | 139 ++++++++++++++ .../Command/TranslationPushCommandTest.php | 173 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 1 + .../Translation/Bridge/Loco/LocoRemote.php | 101 +++++----- .../Bridge/Loco/LocoRemoteFactory.php | 1 - .../Catalogue/AbstractOperation.php | 42 +++++ .../Translation/Remote/AbstractRemote.php | 5 - .../Remote/AbstractRemoteFactory.php | 3 - src/Symfony/Component/Translation/Remotes.php | 7 +- .../Component/Translation/RemotesFactory.php | 13 -- .../Component/Translation/TranslatorBag.php | 72 ++++++-- .../Translation/TranslatorBagInterface.php | 4 +- 21 files changed, 698 insertions(+), 249 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/TranslationTrait.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index e33e0c8df47c8..b45dcb0566a3b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -12,48 +12,45 @@ namespace Symfony\Bundle\FrameworkBundle\Command; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\Component\Translation\Catalogue\MergeOperation; use Symfony\Component\Translation\Catalogue\TargetOperation; -use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Remotes; use Symfony\Component\Translation\Writer\TranslationWriterInterface; /** - * A command that parses templates to extract translation messages and adds them - * into the translation files. - * * @final */ class TranslationPullCommand extends Command { + use TranslationTrait; + protected static $defaultName = 'translation:pull'; private $remotes; private $writer; private $reader; private $defaultLocale; - private $defaultTransPath; + private $transPaths; private $enabledLocales; - public function __construct(Remotes $remotes, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, string $defaultTransPath = null, array $enabledLocales = []) + public function __construct(Remotes $remotes, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) { $this->remotes = $remotes; $this->writer = $writer; - $this->reader = $reader; $this->defaultLocale = $defaultLocale; - $this->defaultTransPath = $defaultTransPath; + $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; + if (null !== $defaultTransPath) { + $this->transPaths[] = $defaultTransPath; + } + parent::__construct(); } @@ -63,17 +60,17 @@ public function __construct(Remotes $remotes, TranslationWriterInterface $writer protected function configure() { $keys = $this->remotes->keys(); - $defaultRemote = 1 === count($keys) ? $keys[0] : null; + $defaultRemote = 1 === \count($keys) ? $keys[0] : null; $this ->setDefinition([ new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to pull translations from.', $defaultRemote), - new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with updated ones'), - new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote'), - new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull'), - new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locels to pull'), - new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'), - new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with remote ones (it will delete not synchronized messages).'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote.'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull. (Do not forget +intl-icu suffix if nedded).'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to pull.'), + new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf'), + new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version.', '1.2'), ]) ->setDescription('Pull translations from a given remote.') ->setHelp(<<<'EOF' @@ -106,50 +103,70 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output): int { - $remoteStorage = $this->remotes->get($input->getArgument('remote')); + $io = new SymfonyStyle($input, $output); - $locales = $input->getOption('locales'); + $remoteStorage = $this->remotes->get($remote = $input->getArgument('remote')); + $locales = $input->getOption('locales') ?: $this->enabledLocales; $domains = $input->getOption('domains'); $force = $input->getOption('force'); $deleteObsolete = $input->getOption('delete-obsolete'); - $remoteTranslations = $remoteStorage->read($domains, $locales); + $writeOptions = [ + 'path' => end($this->transPaths), + ]; - if ($deleteObsolete && $force) { - foreach ($locales as $locale) { - $options = []; + if ($input->getOption('xliff-version')) { + $writeOptions['xliff_version'] = $input->getOption('xliff-version'); + } + + $remoteTranslations = $remoteStorage->read($domains, $locales); - if ($input->getOption('xliff-version')) { - $options['xliff_version'] = $input->getOption('xliff-version'); - } + if ($force) { + if ($deleteObsolete) { + $io->note('The --delete-obsolete option is ineffective with --force'); + } - $this->writer->write($remoteTranslations->getCatalogue($locale), $input->getOption('output-format'), $options); + foreach ($remoteTranslations->getCatalogues() as $catalogue) { + $operation = new TargetOperation((new MessageCatalogue($catalogue->getLocale())), $catalogue); + $operation->moveMessagesToIntlDomainsIfPossible(); + $this->writer->write($operation->getResult(), $input->getOption('output-format'), $writeOptions); } + $io->success(sprintf( + 'Local translations are up to date with %s (for [%s] locale(s), and [%s] domain(s)).', + $remote, + implode(', ', $locales), + implode(', ', $domains) + )); + return 0; } - if ($force) { - // merge all messages from remote to local ones + $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); - return 0; - } else { - // merge only new messages from remote to local ones + if ($deleteObsolete) { + $obsoleteTranslations = $localTranslations->diff($remoteTranslations); + $translationsWithoutObsoleteToWrite = $localTranslations->diff($obsoleteTranslations); - return 0; + foreach ($translationsWithoutObsoleteToWrite->getCatalogues() as $catalogue) { + $this->writer->write($catalogue, $input->getOption('output-format'), $writeOptions); + } + + $io->success('Obsolete translations are locally removed.'); } - if ($deleteObsolete) { - // remove diff between local messages and remote ones + $translationsToWrite = $remoteTranslations->diff($localTranslations); - return 0; + foreach ($translationsToWrite->getCatalogues() as $catalogue) { + $this->writer->write($catalogue, $input->getOption('output-format'), $writeOptions); } - //$this->writer->write($operation->getResult(), $input->getOption('output-format'), [ - //'path' => $bundleTransPath, - //'default_locale' => $this->defaultLocale, - //'xliff_version' => $input->getOption('xliff-version') - //]); + $io->success(sprintf( + 'New remote translations from %s are written locally (for [%s] locale(s), and [%s] domain(s)).', + $remote, + implode(', ', $locales), + implode(', ', $domains) + )); return 0; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index ed8c36bd872f6..b324b9e9d68e5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -18,28 +18,21 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\Component\Translation\Catalogue\MergeOperation; -use Symfony\Component\Translation\Catalogue\TargetOperation; -use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\Loader\ArrayLoader; -use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Remotes; -use Symfony\Component\Translation\TranslatorBag; -use Symfony\Component\Translation\Writer\TranslationWriterInterface; /** * @final */ class TranslationPushCommand extends Command { + use TranslationTrait; + protected static $defaultName = 'translation:push'; private $remotes; private $reader; - private $defaultTransPath; private $transPaths; private $enabledLocales; private $arrayLoader; @@ -48,11 +41,14 @@ public function __construct(Remotes $remotes, TranslationReaderInterface $reader { $this->remotes = $remotes; $this->reader = $reader; - $this->defaultTransPath = $defaultTransPath; $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; $this->arrayLoader = new ArrayLoader(); + if (null !== $defaultTransPath) { + $this->transPaths[] = $defaultTransPath; + } + parent::__construct(); } @@ -62,17 +58,17 @@ public function __construct(Remotes $remotes, TranslationReaderInterface $reader protected function configure() { $keys = $this->remotes->keys(); - $defaultRemote = 1 === count($keys) ? $keys[0] : null; + $defaultRemote = 1 === \count($keys) ? $keys[0] : null; $this ->setDefinition([ - new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to pull translations from.', $defaultRemote), - new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with updated ones'), - new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote'), - new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull'), - new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locels to pull', $this->enabledLocales), - new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'), - new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'), + new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to push translations to.', $defaultRemote), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with local ones (it will delete not synchronized messages).'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available on remote but not locally.'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to push.'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to push.', $this->enabledLocales), + new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf'), + new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version.', '1.2'), ]) ->setDescription('Push translations to a given remote.') ->setHelp(<<<'EOF' @@ -111,70 +107,54 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); - $remoteStorage = $this->remotes->get($input->getArgument('remote')); - + $remoteStorage = $this->remotes->get($remote = $input->getArgument('remote')); + $domains = $input->getOption('domains'); $locales = $input->getOption('locales'); $force = $input->getOption('force'); $deleteObsolete = $input->getOption('delete-obsolete'); - $transPaths = $this->transPaths; - if ($this->defaultTransPath) { - $transPaths[] = $this->defaultTransPath; - } - - /** @var KernelInterface $kernel */ - $kernel = $this->getApplication()->getKernel(); + $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); - // Override with provided Bundle info - foreach ($kernel->getBundles() as $bundle) { - $bundleDir = $bundle->getPath(); - $transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundle->getPath().'/translations'; + if (!$domains) { + $domains = $localTranslations->getDomains(); } - $localTranslations = new TranslatorBag(); - foreach ($locales as $locale) { - $localTranslations->addCatalogue($this->loadCurrentMessages($locale, $transPaths)); - } + if (!$deleteObsolete && $force) { + $remoteStorage->write($localTranslations); - $domains = $input->getOption('domains') ?: $localTranslations->getDomains(); + return 0; + } $remoteTranslations = $remoteStorage->read($domains, $locales); - foreach ($locales as $locale) { - $remoteCatalogue = $remoteTranslations->getCatalogue($locale); - $localCatalogue = $localTranslations->getCatalogue($locale); - - $operation = new TargetOperation($remoteCatalogue, $localCatalogue); - foreach ($domains as $domain) { - if ($force) { - $messages = $operation->getMessages($domain); - } else { - $messages = $operation->getNewMessages($domain); - } - - $bag = new TranslatorBag(); - $bag->addCatalogue($this->arrayLoader->load($messages, $locale, $domain)); - $remoteStorage->write($bag); - - if ($deleteObsolete) { - $obsoleteMessages = $operation->getObsoleteMessages($domain); - $bag = new TranslatorBag(); - $bag->addCatalogue($this->arrayLoader->load($obsoleteMessages, $locale, $domain)); - $remoteStorage->delete($bag); - } - } + if ($deleteObsolete) { + $obsoleteMessages = $remoteTranslations->diff($localTranslations); + $remoteStorage->delete($obsoleteMessages); + + $io->success(sprintf( + 'Obsolete translations on %s are deleted (for [%s] locale(s), and [%s] domain(s)).', + $remote, + implode(', ', $locales), + implode(', ', $domains) + )); } - return 0; - } + $translationsToWrite = $localTranslations->diff($remoteTranslations); - private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue - { - $currentCatalogue = new MessageCatalogue($locale); - foreach ($transPaths as $path) { - $this->reader->read($path, $currentCatalogue); + if ($force) { + $translationsToWrite->addBag($localTranslations->intersect($remoteTranslations)); } - return $currentCatalogue; + $remoteStorage->write($translationsToWrite); + + $io->success(sprintf( + '%s local translations are sent to %s (for [%s] locale(s), and [%s] domain(s)).', + $force ? 'All' : 'New', + $remote, + implode(', ', $locales), + implode(', ', $domains) + )); + + return 0; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationTrait.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationTrait.php new file mode 100644 index 0000000000000..d52222b23f73d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationTrait.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\Bundle\FrameworkBundle\Command; + +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\Component\Translation\TranslatorBag; + +/** + * @internal + */ +trait TranslationTrait +{ + private function readLocalTranslations(array $locales, array $domains, array $transPaths): TranslatorBag + { + $bag = new TranslatorBag(); + + foreach ($locales as $locale) { + $catalogue = new MessageCatalogue($locale); + foreach ($transPaths as $path) { + $this->reader->read($path, $catalogue); + } + + if ($domains) { + foreach ($domains as $domain) { + $catalogue = $this->filterCatalogue($catalogue, $domain); + $bag->addCatalogue($catalogue); + } + } else { + $bag->addCatalogue($catalogue); + } + } + + return $bag; + } + + private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue + { + $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); + + // extract intl-icu messages only + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + if ($intlMessages = $catalogue->all($intlDomain)) { + $filteredCatalogue->add($intlMessages, $intlDomain); + } + + // extract all messages and subtract intl-icu messages + if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { + $filteredCatalogue->add($messages, $domain); + } + foreach ($catalogue->getResources() as $resource) { + $filteredCatalogue->addResource($resource); + } + if ($metadata = $catalogue->getMetadata('', $domain)) { + foreach ($metadata as $k => $v) { + $filteredCatalogue->setMetadata($k, $v, $domain); + } + } + + return $filteredCatalogue; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php index 57a9a19157fa3..71a1752f49ea6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -23,7 +23,6 @@ use Symfony\Component\Translation\Catalogue\TargetOperation; use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; @@ -37,6 +36,8 @@ */ class TranslationUpdateCommand extends Command { + use TranslationTrait; + private const ASC = 'asc'; private const DESC = 'desc'; private const SORT_ORDERS = [self::ASC, self::DESC]; @@ -104,7 +105,7 @@ protected function configure() php %command.full_name% --dump-messages en php %command.full_name% --force --prefix="new_" fr -You can sort the output with the --sort flag: +You can sort the output with the --sort flag: php %command.full_name% --dump-messages --sort=asc en AcmeBundle php %command.full_name% --dump-messages --sort=desc fr @@ -217,23 +218,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $resultMessage = 'Translation files were successfully updated'; - // move new messages to intl domain when possible - if (class_exists(\MessageFormatter::class)) { - foreach ($operation->getDomains() as $domain) { - $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; - $newMessages = $operation->getNewMessages($domain); - - if ([] === $newMessages || ([] === $currentCatalogue->all($intlDomain) && [] !== $currentCatalogue->all($domain))) { - continue; - } - - $result = $operation->getResult(); - $allIntlMessages = $result->all($intlDomain); - $currentMessages = array_diff_key($newMessages, $result->all($domain)); - $result->replace($currentMessages, $domain); - $result->replace($allIntlMessages + $newMessages, $intlDomain); - } - } + $operation->moveMessagesToIntlDomainsIfPossible('new'); // show compiled list of messages if (true === $input->getOption('dump-messages')) { @@ -313,30 +298,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - - private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue - { - $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); - - // extract intl-icu messages only - $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; - if ($intlMessages = $catalogue->all($intlDomain)) { - $filteredCatalogue->add($intlMessages, $intlDomain); - } - - // extract all messages and subtract intl-icu messages - if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { - $filteredCatalogue->add($messages, $domain); - } - foreach ($catalogue->getResources() as $resource) { - $filteredCatalogue->addResource($resource); - } - if ($metadata = $catalogue->getMetadata('', $domain)) { - foreach ($metadata as $k => $v) { - $filteredCatalogue->setMetadata($k, $v, $domain); - } - } - - return $filteredCatalogue; - } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 7c2e89790d3d6..279c7f3ece60b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -81,6 +81,7 @@ class UnusedTagsPass implements CompilerPassInterface 'translation.dumper', 'translation.extractor', 'translation.loader', + 'translation.remote_factory', 'twig.extension', 'twig.loader', 'twig.runtime', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 1957dfbf033e1..98bff1fad9251 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -144,7 +144,6 @@ use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; -use Symfony\Component\Translation\Remote\RemoteInterface; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; @@ -1137,6 +1136,8 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition('console.command.translation_debug'); $container->removeDefinition('console.command.translation_update'); + $container->removeDefinition('console.command.translation_pull'); + $container->removeDefinition('console.command.translation_push'); return; } @@ -1203,6 +1204,42 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->getDefinition('console.command.translation_update')->replaceArgument(6, $transPaths); } + if (!empty($config['remotes'])) { + if (empty($config['enabled_locales'])) { + throw new LogicException('You must specify framework.translator.enabled_locales in order to use remotes.'); + } + + if ($container->hasDefinition('console.command.translation_pull')) { + $container->getDefinition('console.command.translation_pull') + ->replaceArgument(5, $transPaths) + ->replaceArgument(6, $config['enabled_locales']) + ; + } + + if ($container->hasDefinition('console.command.translation_push')) { + $container->getDefinition('console.command.translation_push') + ->replaceArgument(3, $transPaths) + ->replaceArgument(4, $config['enabled_locales']) + ; + } + + $container->getDefinition('translation.remotes_factory') + ->replaceArgument(1, $config['enabled_locales']) + ; + + $container->getDefinition('translation.remotes')->setArgument(0, $config['remotes']); + + $classToServices = [ + LocoRemoteFactory::class => 'translation.remote_factory.loco', + ]; + + foreach ($classToServices as $class => $service) { + if (!class_exists($class)) { + $container->removeDefinition($service); + } + } + } + if ($container->fileExists($defaultDir)) { $dirs[] = $defaultDir; } else { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 37511d2a9e660..cae96e99f4180 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -250,7 +250,6 @@ ->args([ service('translation.remotes'), service('translation.reader'), - param('kernel.default_locale'), param('translator.default_path'), [], // Translator paths [], // Enabled locales diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index cfd1b339af476..7c62a278d7797 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -168,7 +168,7 @@ ->set('translation.remotes', Remotes::class) ->factory([service('translation.remotes_factory'), 'fromConfig']) ->args([ - [], // transports + [], // Remotes ]) ->set('translation.remotes_factory', RemotesFactory::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php index 7eb9cf4624576..050abe8dcb5d3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php @@ -20,12 +20,14 @@ ->args([ service('http_client')->ignoreOnInvalid(), service('translation.loader.xliff_raw'), - param('kernel.default_locale') + param('kernel.default_locale'), ]) ->abstract() ->set('translation.remote_factory.loco', LocoRemoteFactory::class) - ->args([service('translator.data_collector')]) + ->args([ + service('translator.data_collector')->nullOnInvalid(), + ]) ->parent('translation.remote_factory.abstract') ->tag('translation.remote_factory') ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php new file mode 100644 index 0000000000000..7fbb6c6b1152b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Command\TranslationPullCommand; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel; + +class TranslationPullCommandTest extends TestCase +{ + private $fs; + private $translationDir; + + public function testTrue() + { + $this->assertTrue(true); + } + + protected function setUp(): void + { + $this->fs = new Filesystem(); + $this->translationDir = sys_get_temp_dir().'/'.uniqid('sf_translation', true); + $this->fs->mkdir($this->translationDir.'/translations'); + $this->fs->mkdir($this->translationDir.'/templates'); + } + + protected function tearDown(): void + { + $this->fs->remove($this->translationDir); + } + + /** + * @return CommandTester + */ + private function createCommandTester($extractedMessages = [], $loadedMessages = [], HttpKernel\KernelInterface $kernel = null, array $transPaths = [], array $viewsPaths = []) + { + $translator = $this->getMockBuilder('Symfony\Component\Translation\Translator') + ->disableOriginalConstructor() + ->getMock(); + + $translator + ->expects($this->any()) + ->method('getFallbackLocales') + ->willReturn(['en']); + + $extractor = $this->getMockBuilder('Symfony\Component\Translation\Extractor\ExtractorInterface')->getMock(); + $extractor + ->expects($this->any()) + ->method('extract') + ->willReturnCallback( + function ($path, $catalogue) use ($extractedMessages) { + foreach ($extractedMessages as $domain => $messages) { + $catalogue->add($messages, $domain); + } + } + ); + + $loader = $this->getMockBuilder('Symfony\Component\Translation\Reader\TranslationReader')->getMock(); + $loader + ->expects($this->any()) + ->method('read') + ->willReturnCallback( + function ($path, $catalogue) use ($loadedMessages) { + $catalogue->add($loadedMessages); + } + ); + + $writer = $this->getMockBuilder('Symfony\Component\Translation\Writer\TranslationWriter')->getMock(); + $writer + ->expects($this->any()) + ->method('getFormats') + ->willReturn( + ['xlf', 'yml', 'yaml'] + ); + + $remotes = $this->getMockBuilder('Symfony\Component\Translation\Remotes')->getMock(); + $remotes + ->expects($this->any()) + ->method('keys') + ->willReturn( + ['loco'] + ); + + if (null === $kernel) { + $returnValues = [ + ['foo', $this->getBundle($this->translationDir)], + ['test', $this->getBundle('test')], + ]; + $kernel = $this->getMockBuilder('Symfony\Component\HttpKernel\KernelInterface')->getMock(); + $kernel + ->expects($this->any()) + ->method('getBundle') + ->willReturnMap($returnValues); + } + + $kernel + ->expects($this->any()) + ->method('getBundles') + ->willReturn([]); + + $container = new Container(); + $kernel + ->expects($this->any()) + ->method('getContainer') + ->willReturn($container); + + $command = new TranslationPullCommand($remotes, $writer, $loader, 'en', $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); + + $application = new Application($kernel); + $application->add($command); + + return new CommandTester($application->find('translation:pull')); + } + + private function getBundle($path) + { + $bundle = $this->getMockBuilder('Symfony\Component\HttpKernel\Bundle\BundleInterface')->getMock(); + $bundle + ->expects($this->any()) + ->method('getPath') + ->willReturn($path) + ; + + return $bundle; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php new file mode 100644 index 0000000000000..f24e1776d790b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Command; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Command\TranslationPushCommand; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\TranslatorBag; + +class TranslationPushCommandTest extends TestCase +{ + private $fs; + private $translationDir; + + /** + * @dataProvider remotesProvider + */ + public function testPushNewMessages($remotes) + { + $tester = $this->createCommandTester( + ['messages' => ['new.foo' => 'newFoo']], + ['messages' => ['old.foo' => 'oldFoo']], + $remotes, + ['en'], + ['messages'] + ); + foreach ($remotes as $name => $remote) { + $tester->execute([ + 'command' => 'translation:push', + 'remote' => $name, + ]); + $this->assertRegExp('/New local translations are sent to/', $tester->getDisplay()); + } + } + + public function remotesProvider() + { + yield [ + ['loco' => $this->getMockBuilder('Symfony\Component\Translation\Bridge\Loco\LocoRemote')->disableOriginalConstructor()->getMock()], + ]; + } + + protected function setUp(): void + { + $this->fs = new Filesystem(); + $this->translationDir = sys_get_temp_dir().'/'.uniqid('sf_translation', true); + $this->fs->mkdir($this->translationDir.'/translations'); + $this->fs->mkdir($this->translationDir.'/templates'); + } + + protected function tearDown(): void + { + $this->fs->remove($this->translationDir); + } + + /** + * @return CommandTester + */ + private function createCommandTester($remoteMessages = [], $localMessages = [], array $remotes = [], array $locales = [], array $domains = [], HttpKernel\KernelInterface $kernel = null, array $transPaths = []) + { + $translator = $this->getMockBuilder('Symfony\Component\Translation\Translator') + ->disableOriginalConstructor() + ->getMock(); + + $translator + ->expects($this->any()) + ->method('getFallbackLocales') + ->willReturn(['en']); + + $reader = $this->getMockBuilder('Symfony\Component\Translation\Reader\TranslationReader')->getMock(); + $reader + ->expects($this->any()) + ->method('read') + ->willReturnCallback( + function ($path, $catalogue) use ($localMessages) { + $catalogue->add($localMessages); + } + ); + + $writer = $this->getMockBuilder('Symfony\Component\Translation\Writer\TranslationWriter')->getMock(); + $writer + ->expects($this->any()) + ->method('getFormats') + ->willReturn( + ['xlf', 'yml', 'yaml'] + ); + + $remotesMock = $this->getMockBuilder('Symfony\Component\Translation\Remotes') + ->setConstructorArgs([$remotes]) + ->getMock(); + + /** @var MockObject $remote */ + foreach ($remotes as $name => $remote) { + $remote + ->expects($this->any()) + ->method('read') + ->willReturnCallback( + function(array $domains, array $locales) use($remoteMessages) { + $translatorBag = new TranslatorBag(); + foreach ($locales as $locale) { + foreach ($domains as $domain) { + $translatorBag->addCatalogue((new MessageCatalogue($locale, $remoteMessages)), $domain); + } + } + + return $translatorBag; + } + ); + + $remotesMock + ->expects($this->once()) + ->method('get')->with($name) + ->willReturnReference($remote); + } + + if (null === $kernel) { + $returnValues = [ + ['foo', $this->getBundle($this->translationDir)], + ['test', $this->getBundle('test')], + ]; + $kernel = $this->getMockBuilder('Symfony\Component\HttpKernel\KernelInterface')->getMock(); + $kernel + ->expects($this->any()) + ->method('getBundle') + ->willReturnMap($returnValues); + } + + $kernel + ->expects($this->any()) + ->method('getBundles') + ->willReturn([]); + + $container = new Container(); + $kernel + ->expects($this->any()) + ->method('getContainer') + ->willReturn($container); + + $command = new TranslationPushCommand($remotesMock, $reader, $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); + + $application = new Application($kernel); + $application->add($command); + + return new CommandTester($application->find('translation:push')); + } + + private function getBundle($path) + { + $bundle = $this->getMockBuilder('Symfony\Component\HttpKernel\Bundle\BundleInterface')->getMock(); + $bundle + ->expects($this->any()) + ->method('getPath') + ->willReturn($path) + ; + + return $bundle; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 00afdfd00055f..71d87458a16a4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -387,6 +387,7 @@ protected static function getBundleDefaultConfig() 'parse_html' => false, 'localizable_html_attributes' => [], ], + 'remotes' => [], ], 'validation' => [ 'enabled' => !class_exists(FullStack::class), diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php index 45a95f45cbe6a..afc238b7fccdb 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -12,11 +12,9 @@ namespace Symfony\Component\Translation\Bridge\Loco; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\RemoteException; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\Message\MessageInterface; -use Symfony\Component\Translation\Message\SmsMessage; +use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Remote\AbstractRemote; use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -24,14 +22,15 @@ /** * @author Fabien Potencier * - * @experimental in 5.1 + * @experimental in 5.2 + * @final * * In Loco: * tags refers to Symfony's translation domains * assets refers to Symfony's translation keys * translations refers to Symfony's translation messages */ -final class LocoRemote extends AbstractRemote +class LocoRemote extends AbstractRemote { protected const HOST = 'localise.biz'; @@ -58,8 +57,9 @@ public function __toString(): string */ public function write(TranslatorBag $translations, bool $override = false): void { - foreach ($translations->all() as $locale => $messages) { - foreach ($messages as $domain => $messages) { + foreach ($translations->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $domain => $messages) { + $locale = $catalogue->getLocale(); $ids = []; foreach ($messages as $id => $message) { @@ -68,7 +68,9 @@ public function write(TranslatorBag $translations, bool $override = false): void $this->translateAsset($id, $message, $locale); } - $this->tagsAssets($ids, $domain); + if (!empty($ids)) { + $this->tagsAssets($ids, $domain); + } } } } @@ -79,34 +81,23 @@ public function write(TranslatorBag $translations, bool $override = false): void public function read(array $domains, array $locales): TranslatorBag { $filter = $domains ? implode(',', $domains) : '*'; + $translatorBag = new TranslatorBag(); - if (1 === count($locales)) { - $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locales[0], $filter), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], - ]); - } else { - $response = $this->client->request('GET', sprintf('https://%s/api/export/all.xlf?filter=%s', $this->getEndpoint(), $filter), [ + foreach ($locales as $locale) { + $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ 'headers' => [ 'Authorization' => 'Loco '.$this->apiKey, ], ]); - } - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException('Unable to read the Loco response: '.$response->getContent(false), $response); - } + $responseContent = $response->getContent(false); - $translatorBag = new TranslatorBag(); + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException('Unable to read the Loco response: '.$responseContent, $response); + } - foreach ($locales as $locale) { - if (\count($domains) > 1) { - foreach ($domains as $domain) { - $translatorBag->addCatalogue($this->loader->load($response->getContent(), $locale, $domain)); - } - } else { - $translatorBag->addCatalogue($this->loader->load($response->getContent(), $locale, $domains[0] ?? 'messages')); // not sure + foreach ($domains as $domain) { + $translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain)); } } @@ -118,16 +109,24 @@ public function read(array $domains, array $locales): TranslatorBag */ public function delete(TranslatorBag $translations): void { + $deletedIds = []; + foreach ($translations->all() as $locale => $messages) { foreach ($messages as $domain => $messages) { foreach ($messages as $id => $message) { + if (\in_array($id, $deletedIds)) { + continue; + } + $this->deleteAsset($id); + + $deletedIds[] = $id; } } } } - private function createAsset(string $id) + private function createAsset(string $id): void { $response = $this->client->request('POST', sprintf('https://%s/api/assets', $this->getEndpoint()), [ 'headers' => [ @@ -138,17 +137,17 @@ private function createAsset(string $id) 'id' => $id, 'type' => 'text', 'default' => 'untranslated', - ] + ], ]); - if ($response->getStatusCode() === Response::HTTP_CONFLICT) { + if (Response::HTTP_CONFLICT === $response->getStatusCode()) { // Translation key already exists in Loco, do nothing - } elseif ($response->getStatusCode() !== Response::HTTP_CREATED || $response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: %s', $id, $response->getContent(false)), $response); + } elseif (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); } } - private function translateAsset(string $id, string $message, string $locale) + private function translateAsset(string $id, string $message, string $locale): void { $response = $this->client->request('POST', sprintf('https://%s/api/translations/%s/%s', $this->getEndpoint(), $id, $locale), [ 'headers' => [ @@ -157,12 +156,12 @@ private function translateAsset(string $id, string $message, string $locale) 'body' => $message, ]); - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to add translation message (for key: %s) to Loco: %s', $id, $response->getContent(false)), $response); + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add translation message (for key: "%s") to Loco: "%s".', $id, $response->getContent(false)), $response); } } - private function tagsAssets(array $ids, string $tag) + private function tagsAssets(array $ids, string $tag): void { $idsAsString = implode(',', array_unique($ids)); @@ -177,22 +176,24 @@ private function tagsAssets(array $ids, string $tag) 'body' => $idsAsString, ]); - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to add tag (%s) on translation keys (%s) to Loco: %s', $tag, $idsAsString, $response->getContent(false)), $response); + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add tag (%s) on translation keys (%s) to Loco: "%s".', $tag, $idsAsString, $response->getContent(false)), $response); } } - private function createTag(string $tag) + private function createTag(string $tag): void { $response = $this->client->request('POST', sprintf('https://%s/api/tags.json', $this->getEndpoint(), $tag), [ 'headers' => [ 'Authorization' => 'Loco '.$this->apiKey, ], - 'name' => $tag, + 'body' => [ + 'name' => $tag, + ], ]); - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to create tag (%s) on Loco: %s', $tag, $response->getContent(false)), $response); + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to create tag (%s) on Loco: "%s".', $tag, $response->getContent(false)), $response); } } @@ -204,25 +205,25 @@ private function getTags(): array ], ]); - $content = $response->getContent(); + $content = $response->getContent(false); - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to get tags on Loco: %s', $response->getContent(false)), $response); + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to get tags on Loco: "%s".', $content), $response); } return json_decode($content); } - private function deleteAsset(string $id) + private function deleteAsset(string $id): void { $response = $this->client->request('DELETE', sprintf('https://%s/api/assets/%s.json', $this->getEndpoint(), $id), [ 'headers' => [ 'Authorization' => 'Loco '.$this->apiKey, - ] + ], ]); - if ($response->getStatusCode() !== Response::HTTP_OK) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: %s', $id, $response->getContent(false)), $response); + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: "%s".', $id, $response->getContent(false)), $response); } } } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php index 491acd56cc7b1..7dc4e45313c8b 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Translation\Bridge\Loco; -use Symfony\Component\Translation\Exception\InvalidArgumentException; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; use Symfony\Component\Translation\Remote\AbstractRemoteFactory; use Symfony\Component\Translation\Remote\Dsn; diff --git a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php index 17c257fde458e..1fc3f61236662 100644 --- a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php +++ b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php @@ -147,6 +147,48 @@ public function getResult() return $this->result; } + /** + * @param string $domain + * @param OperationInterface $operation + * @param string $batch must be one of ['all', 'obsolete', 'new'] + */ + public function moveMessagesToIntlDomainsIfPossible(string $batch = 'all'): void + { + if (!class_exists(\MessageFormatter::class)) { + return; + } + + if (!\in_array($batch, ['all', 'obsolete', 'new'])) { + throw new \InvalidArgumentException('$batch argument must be one of [\'all\', \'obsolete\', \'new\'].'); + } + + foreach ($this->getDomains() as $domain) { + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + switch ($batch) { + case 'obsolete': + $messages = $this->getObsoleteMessages($domain); + break; + case 'new': + $messages = $this->getNewMessages($domain); + break; + case 'all': + default: + $messages = $this->getMessages($domain); + break; + } + + if ([] === $messages || ([] === $this->source->all($intlDomain) && [] !== $this->source->all($domain))) { + continue; + } + + $result = $this->getResult(); + $allIntlMessages = $result->all($intlDomain); + $currentMessages = array_diff_key($messages, $result->all($domain)); + $result->replace($currentMessages, $domain); + $result->replace($allIntlMessages + $messages, $intlDomain); + } + } + /** * Performs operation on source and target catalogues for the given domain and * stores the results. diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemote.php b/src/Symfony/Component/Translation/Remote/AbstractRemote.php index 75d4b2667ec00..0c301d05bf6e2 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemote.php +++ b/src/Symfony/Component/Translation/Remote/AbstractRemote.php @@ -11,13 +11,8 @@ namespace Symfony\Component\Translation\Remote; -use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\Translation\Event\MessageEvent; use Symfony\Component\Translation\Exception\LogicException; -use Symfony\Component\Translation\Message\MessageInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; abstract class AbstractRemote implements RemoteInterface diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php index 3f6fd6fa9d15a..b51b7409ddb9d 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php +++ b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php @@ -11,11 +11,8 @@ namespace Symfony\Component\Translation\Remote; -use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; use Symfony\Component\Translation\Exception\IncompleteDsnException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; abstract class AbstractRemoteFactory implements RemoteFactoryInterface diff --git a/src/Symfony/Component/Translation/Remotes.php b/src/Symfony/Component/Translation/Remotes.php index fc568597149ae..14862534c7f7f 100644 --- a/src/Symfony/Component/Translation/Remotes.php +++ b/src/Symfony/Component/Translation/Remotes.php @@ -14,7 +14,10 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException; use Symfony\Component\Translation\Remote\RemoteInterface; -final class Remotes +/** + * @final + */ +class Remotes { private $remotes; @@ -42,7 +45,7 @@ public function has(string $name): bool public function get(string $name): RemoteInterface { if (!$this->has($name)) { - throw new InvalidArgumentException(sprintf('Remote "%s" not found. Available: %s', $name, (string) $this)); + throw new InvalidArgumentException(sprintf('Remote "%s" not found. Available: "%s".', $name, (string) $this)); } return $this->remotes[$name]; diff --git a/src/Symfony/Component/Translation/RemotesFactory.php b/src/Symfony/Component/Translation/RemotesFactory.php index 12b865a725ea6..14a38feaf597f 100644 --- a/src/Symfony/Component/Translation/RemotesFactory.php +++ b/src/Symfony/Component/Translation/RemotesFactory.php @@ -11,25 +11,12 @@ namespace Symfony\Component\Translation; -use Symfony\Component\Translation\Bridge\Firebase\FirebaseRemoteFactory; -use Symfony\Component\Translation\Bridge\FreeMobile\FreeMobileRemoteFactory; -use Symfony\Component\Translation\Bridge\Mattermost\MattermostRemoteFactory; -use Symfony\Component\Translation\Bridge\Nexmo\NexmoRemoteFactory; -use Symfony\Component\Translation\Bridge\OvhCloud\OvhCloudRemoteFactory; -use Symfony\Component\Translation\Bridge\RocketChat\RocketChatRemoteFactory; -use Symfony\Component\Translation\Bridge\Sinch\SinchRemoteFactory; -use Symfony\Component\Translation\Bridge\Slack\SlackRemoteFactory; -use Symfony\Component\Translation\Bridge\Telegram\TelegramRemoteFactory; -use Symfony\Component\Translation\Bridge\Twilio\TwilioRemoteFactory; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; use Symfony\Component\Translation\Remote\Dsn; -use Symfony\Component\Translation\Remote\FailoverRemote; use Symfony\Component\Translation\Remote\NullRemoteFactory; -use Symfony\Component\Translation\Remote\RoundRobinRemote; use Symfony\Component\Translation\Remote\RemoteDecorator; use Symfony\Component\Translation\Remote\RemoteFactoryInterface; use Symfony\Component\Translation\Remote\RemoteInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class RemotesFactory diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php index b0433f00d568c..8caebd209222a 100644 --- a/src/Symfony/Component/Translation/TranslatorBag.php +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Translation; +use Symfony\Component\Translation\Catalogue\TargetOperation; + final class TranslatorBag implements TranslatorBagInterface { /** @var MessageCatalogue[] */ @@ -18,15 +20,26 @@ final class TranslatorBag implements TranslatorBagInterface public function addCatalogue(MessageCatalogue $catalogue): void { + if (null !== $existingCatalogue = $this->getCatalogue($catalogue->getLocale())) { + $catalogue->addCatalogue($existingCatalogue); + } + $this->catalogues[$catalogue->getLocale()] = $catalogue; } + public function addBag(self $bag): void + { + foreach ($bag->getCatalogues() as $catalogue) { + $this->addCatalogue($catalogue); + } + } + public function getDomains(): array { $domains = []; foreach ($this->catalogues as $catalogue) { - $domains += $catalogue->getDomains(); + $domains += $catalogue->all(); } return array_unique($domains); @@ -35,25 +48,60 @@ public function getDomains(): array public function all(): array { $messages = []; + foreach ($this->catalogues as $locale => $catalogue) { + $messages[$locale] = $catalogue->all(); + } - foreach ($this->catalogues as $catalogue) { - $locale = $catalogue->getLocale(); - if (!isset($messages[$locale])) { - $messages[$locale] = $catalogue->all(); - } else { - $messages[$locale] = array_merge($messages[$locale], $catalogue->all()); + return $messages; + } + + public function getCatalogue(string $locale): ?MessageCatalogue + { + return $this->catalogues[$locale] ?? null; + } + + /** + * @return MessageCatalogueInterface[] + */ + public function getCatalogues(): array + { + return array_values($this->catalogues); + } + + public function diff(self $diffBag): self + { + $diff = new self(); + + foreach ($this->catalogues as $locale => $catalogue) { + if (null === $diffCatalogue = $diffBag->getCatalogue($locale)) { + $diff->addCatalogue($catalogue); + + continue; } + + $operation = new TargetOperation($catalogue, $diffCatalogue); + $operation->moveMessagesToIntlDomainsIfPossible('obsolete'); + $diff->addCatalogue($operation->getResult()); } - return $messages; + return $diff; } - public function getCatalogue(string $locale = null): ?MessageCatalogue + public function intersect(self $intersectBag): self { - if (!$locale) { - return null; + $diff = new self(); + + foreach ($this->catalogues as $locale => $catalogue) { + if (null === $intersectCatalogue = $intersectBag->getCatalogue($locale)) { + continue; + } + + $operation = new TargetOperation($catalogue, $intersectCatalogue); + $operation->moveMessagesToIntlDomainsIfPossible('obsolete'); + + $diff->addCatalogue($operation->getResult()); } - return $this->catalogues[$locale]; + return $diff; } } diff --git a/src/Symfony/Component/Translation/TranslatorBagInterface.php b/src/Symfony/Component/Translation/TranslatorBagInterface.php index e40ca8a23bf49..177007bd7256a 100644 --- a/src/Symfony/Component/Translation/TranslatorBagInterface.php +++ b/src/Symfony/Component/Translation/TranslatorBagInterface.php @@ -23,11 +23,9 @@ interface TranslatorBagInterface /** * Gets the catalogue by locale. * - * @param string|null $locale The locale or null to use the default - * * @return MessageCatalogueInterface * * @throws InvalidArgumentException If the locale contains invalid characters */ - public function getCatalogue(string $locale = null); + public function getCatalogue(string $locale); } From 33f192a49d4d3db7497c4ea65b7d5386c81878a0 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 7 Aug 2020 12:14:55 +0200 Subject: [PATCH 05/14] Revert signature change because of possible BC Break --- .../Tests/Command/TranslationPushCommandTest.php | 2 +- .../Component/Translation/Bridge/Loco/LocoRemote.php | 1 - src/Symfony/Component/Translation/TranslatorBag.php | 6 +++++- .../Component/Translation/TranslatorBagInterface.php | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php index f24e1776d790b..296e57fe5d7b1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php @@ -110,7 +110,7 @@ function ($path, $catalogue) use ($localMessages) { ->expects($this->any()) ->method('read') ->willReturnCallback( - function(array $domains, array $locales) use($remoteMessages) { + function (array $domains, array $locales) use ($remoteMessages) { $translatorBag = new TranslatorBag(); foreach ($locales as $locale) { foreach ($domains as $domain) { diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php index afc238b7fccdb..424799d3f5b33 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -14,7 +14,6 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Remote\AbstractRemote; use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php index 8caebd209222a..e8d0509c0b39e 100644 --- a/src/Symfony/Component/Translation/TranslatorBag.php +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -55,8 +55,12 @@ public function all(): array return $messages; } - public function getCatalogue(string $locale): ?MessageCatalogue + public function getCatalogue(string $locale = null): ?MessageCatalogue { + if (null === $locale) { + return null; + } + return $this->catalogues[$locale] ?? null; } diff --git a/src/Symfony/Component/Translation/TranslatorBagInterface.php b/src/Symfony/Component/Translation/TranslatorBagInterface.php index 177007bd7256a..5484e45c9460b 100644 --- a/src/Symfony/Component/Translation/TranslatorBagInterface.php +++ b/src/Symfony/Component/Translation/TranslatorBagInterface.php @@ -27,5 +27,5 @@ interface TranslatorBagInterface * * @throws InvalidArgumentException If the locale contains invalid characters */ - public function getCatalogue(string $locale); + public function getCatalogue(string $locale = null); } From a1e23e6298f4bd9ef09b443ba0bbe2c5fed2cc7f Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 7 Aug 2020 15:57:19 +0200 Subject: [PATCH 06/14] Init Crowdin remote storage --- .../Resources/config/translation_remotes.php | 8 +++ .../Bridge/Crowdin/CrowdinRemote.php | 65 +++++++++++++++++++ .../Bridge/Crowdin/CrowdinRemoteFactory.php | 45 +++++++++++++ .../Exception/IncompleteDsnException.php | 24 +++++++ .../Exception/UnsupportedSchemeException.php | 40 ++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php create mode 100644 src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php create mode 100644 src/Symfony/Component/Translation/Exception/IncompleteDsnException.php create mode 100644 src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php index 050abe8dcb5d3..4935bf1650cc5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Translation\Bridge\Crowdin\CrowdinRemoteFactory; use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; use Symfony\Component\Translation\Remote\AbstractRemoteFactory; @@ -30,5 +31,12 @@ ]) ->parent('translation.remote_factory.abstract') ->tag('translation.remote_factory') + + ->set('translation.remote_factory.crowdin', CrowdinRemoteFactory::class) + ->args([ + service('translator.data_collector')->nullOnInvalid(), + ]) + ->parent('translation.remote_factory.abstract') + ->tag('translation.remote_factory') ; }; diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php new file mode 100644 index 0000000000000..4708cb927c762 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Crowdin; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Remote\AbstractRemote; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + * @final + * + * In Crowdin: + */ +class CrowdinRemote extends AbstractRemote +{ + protected const HOST = 'crowdin.com/api/v2'; + + private $apiKey; + private $loader; + private $defaultLocale; + + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) + { + $this->apiKey = $apiKey; + $this->loader = $loader; + $this->defaultLocale = $defaultLocale; + + parent::__construct($client); + } + + public function __toString(): string + { + return sprintf('crowdin://%s', $this->getEndpoint()); + } + + public function write(TranslatorBag $translations, bool $override = false): void + { + // TODO: Implement write() method. + } + + public function read(array $domains, array $locales): TranslatorBag + { + // TODO: Implement read() method. + } + + public function delete(TranslatorBag $translations): void + { + // TODO: Implement delete() method. + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php new file mode 100644 index 0000000000000..0c5754c770ca4 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Crowdin; + +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Remote\AbstractRemoteFactory; +use Symfony\Component\Translation\Remote\Dsn; +use Symfony\Component\Translation\Remote\RemoteInterface; + +final class CrowdinRemoteFactory extends AbstractRemoteFactory +{ + /** + * @return CrowdinRemote + */ + public function create(Dsn $dsn): RemoteInterface + { + $scheme = $dsn->getScheme(); + $apiKey = $this->getUser($dsn); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('crowdin' === $scheme) { + return (new CrowdinRemote($apiKey, $this->client, $this->loader, $this->defaultLocale)) + ->setHost($host) + ->setPort($port) + ; + } + + throw new UnsupportedSchemeException($dsn, 'crowdin', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['crowdin']; + } +} diff --git a/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php new file mode 100644 index 0000000000000..edc3e03cfe21b --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +class IncompleteDsnException extends InvalidArgumentException +{ + public function __construct(string $message, string $dsn = null, ?\Throwable $previous = null) + { + if ($dsn) { + $message = sprintf('Invalid "%s" remote storage DSN: ', $dsn).$message; + } + + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php new file mode 100644 index 0000000000000..0221567248ca3 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +use Symfony\Component\Translation\Remote\Dsn; + +class UnsupportedSchemeException extends LogicException +{ + private const SCHEME_TO_PACKAGE_MAP = []; + + public function __construct(Dsn $dsn, string $name = null, array $supported = []) + { + $provider = $dsn->getScheme(); + if (false !== $pos = strpos($provider, '+')) { + $provider = substr($provider, 0, $pos); + } + $package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null; + if ($package && !class_exists($package['class'])) { + parent::__construct(sprintf('Unable to send emails via "%s" as the bridge is not installed; try running "composer require %s".', $provider, $package['package'])); + + return; + } + + $message = sprintf('The "%s" scheme is not supported', $dsn->getScheme()); + if ($name && $supported) { + $message .= sprintf('; supported schemes for mailer "%s" are: "%s"', $name, implode('", "', $supported)); + } + + parent::__construct($message.'.'); + } +} From 0fd2e89206b3bfa0cad2804758011d9b4c5e532c Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Mon, 10 Aug 2020 12:16:27 +0200 Subject: [PATCH 07/14] Improve DX in Remotes Storages --- .../Resources/config/translation_remotes.php | 1 + .../Bridge/Crowdin/CrowdinRemote.php | 2 - .../Translation/Bridge/Loco/LocoRemote.php | 57 ++++++++++--------- .../Bridge/Loco/LocoRemoteFactory.php | 2 +- .../Translation/Remote/AbstractRemote.php | 5 ++ .../Remote/AbstractRemoteFactory.php | 12 +++- 6 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php index 4935bf1650cc5..5b59ce83606bc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php @@ -21,6 +21,7 @@ ->args([ service('http_client')->ignoreOnInvalid(), service('translation.loader.xliff_raw'), + service('logger')->nullOnInvalid(), param('kernel.default_locale'), ]) ->abstract() diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php index 4708cb927c762..628d619a74f42 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php @@ -11,8 +11,6 @@ namespace Symfony\Component\Translation\Bridge\Crowdin; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Remote\AbstractRemote; use Symfony\Component\Translation\TranslatorBag; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php index 424799d3f5b33..98d1b008070c6 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Translation\Bridge\Loco; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; @@ -33,14 +34,23 @@ class LocoRemote extends AbstractRemote { protected const HOST = 'localise.biz'; + /** @var string */ private $apiKey; + + /** @var LoaderInterface|null */ private $loader; + + /** @var LoggerInterface|null */ + private $logger; + + /** @var string|null */ private $defaultLocale; - public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) { $this->apiKey = $apiKey; $this->loader = $loader; + $this->logger = $logger; $this->defaultLocale = $defaultLocale; parent::__construct($client); @@ -84,9 +94,7 @@ public function read(array $domains, array $locales): TranslatorBag foreach ($locales as $locale) { $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), ]); $responseContent = $response->getContent(false); @@ -110,8 +118,8 @@ public function delete(TranslatorBag $translations): void { $deletedIds = []; - foreach ($translations->all() as $locale => $messages) { - foreach ($messages as $domain => $messages) { + foreach ($translations->all() as $locale => $domainMessages) { + foreach ($domainMessages as $domain => $messages) { foreach ($messages as $id => $message) { if (\in_array($id, $deletedIds)) { continue; @@ -125,12 +133,17 @@ public function delete(TranslatorBag $translations): void } } + protected function getDefaultHeaders(): array + { + return [ + 'Authorization' => 'Loco '.$this->apiKey, + ]; + } + private function createAsset(string $id): void { $response = $this->client->request('POST', sprintf('https://%s/api/assets', $this->getEndpoint()), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), 'body' => [ 'name' => $id, 'id' => $id, @@ -140,7 +153,9 @@ private function createAsset(string $id): void ]); if (Response::HTTP_CONFLICT === $response->getStatusCode()) { - // Translation key already exists in Loco, do nothing + $this->logger->warning(sprintf('Translation key (%s) already exists in Loco.', $id), [ + 'id' => $id, + ]); } elseif (Response::HTTP_CREATED !== $response->getStatusCode()) { throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); } @@ -149,9 +164,7 @@ private function createAsset(string $id): void private function translateAsset(string $id, string $message, string $locale): void { $response = $this->client->request('POST', sprintf('https://%s/api/translations/%s/%s', $this->getEndpoint(), $id, $locale), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), 'body' => $message, ]); @@ -169,9 +182,7 @@ private function tagsAssets(array $ids, string $tag): void } $response = $this->client->request('POST', sprintf('https://%s/api/tags/%s.json', $this->getEndpoint(), $tag), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), 'body' => $idsAsString, ]); @@ -182,10 +193,8 @@ private function tagsAssets(array $ids, string $tag): void private function createTag(string $tag): void { - $response = $this->client->request('POST', sprintf('https://%s/api/tags.json', $this->getEndpoint(), $tag), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + $response = $this->client->request('POST', sprintf('https://%s/api/tags.json', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), 'body' => [ 'name' => $tag, ], @@ -199,9 +208,7 @@ private function createTag(string $tag): void private function getTags(): array { $response = $this->client->request('GET', sprintf('https://%s/api/tags.json', $this->getEndpoint()), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), ]); $content = $response->getContent(false); @@ -216,9 +223,7 @@ private function getTags(): array private function deleteAsset(string $id): void { $response = $this->client->request('DELETE', sprintf('https://%s/api/assets/%s.json', $this->getEndpoint(), $id), [ - 'headers' => [ - 'Authorization' => 'Loco '.$this->apiKey, - ], + 'headers' => $this->getDefaultHeaders(), ]); if (Response::HTTP_OK !== $response->getStatusCode()) { diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php index 7dc4e45313c8b..a825ee02a317e 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php @@ -29,7 +29,7 @@ public function create(Dsn $dsn): RemoteInterface $port = $dsn->getPort(); if ('loco' === $scheme) { - return (new LocoRemote($apiKey, $this->client, $this->loader, $this->defaultLocale)) + return (new LocoRemote($apiKey, $this->client, $this->logger, $this->loader, $this->defaultLocale)) ->setHost($host) ->setPort($port) ; diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemote.php b/src/Symfony/Component/Translation/Remote/AbstractRemote.php index 0c301d05bf6e2..c3002b4abf6cf 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemote.php +++ b/src/Symfony/Component/Translation/Remote/AbstractRemote.php @@ -64,4 +64,9 @@ protected function getDefaultHost(): string { return static::HOST; } + + protected function getDefaultHeaders(): array + { + return []; + } } diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php index b51b7409ddb9d..f5b8e393dcae9 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php +++ b/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php @@ -11,20 +11,30 @@ namespace Symfony\Component\Translation\Remote; +use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\IncompleteDsnException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; abstract class AbstractRemoteFactory implements RemoteFactoryInterface { + /** @var HttpClientInterface|null */ protected $client; + + /** @var LoaderInterface|null */ protected $loader; + + /** @var LoggerInterface|null */ + protected $logger; + + /** @var string|null */ protected $defaultLocale; - public function __construct(HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) + public function __construct(HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) { $this->client = $client; $this->loader = $loader; + $this->logger = $logger; $this->defaultLocale = $defaultLocale; } From d66519734049c2549af614920c88e6e07ee3920f Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Mon, 21 Sep 2020 10:14:29 +0200 Subject: [PATCH 08/14] Taken into consideration first review --- .../Command/TranslationPullCommand.php | 13 ++--- .../Command/TranslationPushCommand.php | 11 ++-- .../Command/TranslationUpdateCommand.php | 2 +- .../FrameworkExtension.php | 4 +- .../Bridge/Crowdin/CrowdinRemote.php | 33 +++++++++-- .../Bridge/Crowdin/CrowdinRemoteFactory.php | 13 ++--- .../Translation/Bridge/Loco/LocoRemote.php | 3 +- .../Bridge/Loco/LocoRemoteFactory.php | 13 ++--- .../Exception/TransportException.php | 2 +- .../Translation/Remote/NullRemote.php | 56 ------------------- .../Translation/Remote/NullRemoteFactory.php | 34 ----------- .../Translation/Remote/RemoteDecorator.php | 2 +- .../Translation/Remote/RemoteInterface.php | 10 ++-- src/Symfony/Component/Translation/Remotes.php | 5 +- .../Component/Translation/RemotesFactory.php | 24 +------- 15 files changed, 59 insertions(+), 166 deletions(-) delete mode 100644 src/Symfony/Component/Translation/Remote/NullRemote.php delete mode 100644 src/Symfony/Component/Translation/Remote/NullRemoteFactory.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index b45dcb0566a3b..312dde0080466 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -23,10 +23,7 @@ use Symfony\Component\Translation\Remotes; use Symfony\Component\Translation\Writer\TranslationWriterInterface; -/** - * @final - */ -class TranslationPullCommand extends Command +final class TranslationPullCommand extends Command { use TranslationTrait; @@ -81,7 +78,7 @@ protected function configure() php %command.full_name% --force remote -You can remote local translations which are not present on the remote: +You can remove local translations which are not present on the remote: php %command.full_name% --delete-obsolete remote @@ -89,10 +86,10 @@ protected function configure() php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en -This command will pull all translations linked to domains messages & validators -for the locale en. Local translations for the specified domains & locale will +This command will pull all translations linked to domains messages and validators +for the locale en. Local translations for the specified domains and locale will be erased if they're not present on the remote and overwritten if it's the -case. Local translations for others domains & locales will be ignored. +case. Local translations for others domains and locales will be ignored. EOF ) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index b324b9e9d68e5..831e395b54be0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -22,10 +22,7 @@ use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Remotes; -/** - * @final - */ -class TranslationPushCommand extends Command +final class TranslationPushCommand extends Command { use TranslationTrait; @@ -87,10 +84,10 @@ protected function configure() php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en -This command will push all translations linked to domains messages & validators -for the locale en. Remote translations for the specified domains & locale will +This command will push all translations linked to domains messages and validators +for the locale en. Remote translations for the specified domains and locale will be erased if they're not present locally and overwritten if it's the -case. Remote translations for others domains & locales will be ignored. +case. Remote translations for others domains and locales will be ignored. EOF ) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php index 71a1752f49ea6..393f34cdbe558 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -105,7 +105,7 @@ protected function configure() php %command.full_name% --dump-messages en php %command.full_name% --force --prefix="new_" fr -You can sort the output with the --sort flag: +You can sort the output with the --sort flag: php %command.full_name% --dump-messages --sort=asc en AcmeBundle php %command.full_name% --dump-messages --sort=desc fr diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 98bff1fad9251..96336b9b90fa8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1204,8 +1204,8 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->getDefinition('console.command.translation_update')->replaceArgument(6, $transPaths); } - if (!empty($config['remotes'])) { - if (empty($config['enabled_locales'])) { + if ($config['remotes']) { + if (!$config['enabled_locales']) { throw new LogicException('You must specify framework.translator.enabled_locales in order to use remotes.'); } diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php index 628d619a74f42..37f5a1854d38a 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php @@ -11,6 +11,9 @@ namespace Symfony\Component\Translation\Bridge\Crowdin; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Remote\AbstractRemote; use Symfony\Component\Translation\TranslatorBag; @@ -20,22 +23,23 @@ * @author Fabien Potencier * * @experimental in 5.2 - * @final * * In Crowdin: */ -class CrowdinRemote extends AbstractRemote +final class CrowdinRemote extends AbstractRemote { - protected const HOST = 'crowdin.com/api/v2'; + protected const HOST = 'api.crowdin.com'; private $apiKey; private $loader; + private $logger; private $defaultLocale; - public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, string $defaultLocale = null) + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) { $this->apiKey = $apiKey; $this->loader = $loader; + $this->logger = $logger; $this->defaultLocale = $defaultLocale; parent::__construct($client); @@ -53,7 +57,26 @@ public function write(TranslatorBag $translations, bool $override = false): void public function read(array $domains, array $locales): TranslatorBag { - // TODO: Implement read() method. + $filter = $domains ? implode(',', $domains) : '*'; + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ + 'headers' => $this->getDefaultHeaders(), + ]); + + $responseContent = $response->getContent(false); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException('Unable to read the Loco response: '.$responseContent, $response); + } + + foreach ($domains as $domain) { + $translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain)); + } + } + + return $translatorBag; } public function delete(TranslatorBag $translations): void diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php index 0c5754c770ca4..44c0676532209 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php @@ -23,15 +23,10 @@ final class CrowdinRemoteFactory extends AbstractRemoteFactory */ public function create(Dsn $dsn): RemoteInterface { - $scheme = $dsn->getScheme(); - $apiKey = $this->getUser($dsn); - $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); - $port = $dsn->getPort(); - - if ('crowdin' === $scheme) { - return (new CrowdinRemote($apiKey, $this->client, $this->loader, $this->defaultLocale)) - ->setHost($host) - ->setPort($port) + if ('crowdin' === $dsn->getScheme()) { + return (new CrowdinRemote($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) + ->setPort($dsn->getPort()) ; } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php index 98d1b008070c6..748cb9ebe8b72 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php @@ -23,14 +23,13 @@ * @author Fabien Potencier * * @experimental in 5.2 - * @final * * In Loco: * tags refers to Symfony's translation domains * assets refers to Symfony's translation keys * translations refers to Symfony's translation messages */ -class LocoRemote extends AbstractRemote +final class LocoRemote extends AbstractRemote { protected const HOST = 'localise.biz'; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php index a825ee02a317e..cf7766fb91ffd 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php @@ -23,15 +23,10 @@ final class LocoRemoteFactory extends AbstractRemoteFactory */ public function create(Dsn $dsn): RemoteInterface { - $scheme = $dsn->getScheme(); - $apiKey = $this->getUser($dsn); - $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); - $port = $dsn->getPort(); - - if ('loco' === $scheme) { - return (new LocoRemote($apiKey, $this->client, $this->logger, $this->loader, $this->defaultLocale)) - ->setHost($host) - ->setPort($port) + if ('loco' === $dsn->getScheme()) { + return (new LocoRemote($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) + ->setPort($dsn->getPort()) ; } diff --git a/src/Symfony/Component/Translation/Exception/TransportException.php b/src/Symfony/Component/Translation/Exception/TransportException.php index 427900e8e6740..7f754617525c4 100644 --- a/src/Symfony/Component/Translation/Exception/TransportException.php +++ b/src/Symfony/Component/Translation/Exception/TransportException.php @@ -16,7 +16,7 @@ /** * @author Fabien Potencier * - * @experimental in 5.1 + * @experimental in 5.2 */ class TransportException extends RuntimeException implements TransportExceptionInterface { diff --git a/src/Symfony/Component/Translation/Remote/NullRemote.php b/src/Symfony/Component/Translation/Remote/NullRemote.php deleted file mode 100644 index 1940097cba4e1..0000000000000 --- a/src/Symfony/Component/Translation/Remote/NullRemote.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Translation\Remote; - -use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; -use Symfony\Component\Translation\Event\MessageEvent; -use Symfony\Component\Translation\Message\MessageInterface; -use Symfony\Component\Translation\TranslatorBag; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -class NullRemote implements RemoteInterface -{ - private $dispatcher; - - public function __construct(EventDispatcherInterface $dispatcher = null) - { - $this->dispatcher = class_exists(Event::class) ? LegacyEventDispatcherProxy::decorate($dispatcher) : $dispatcher; - } - - public function send(MessageInterface $message): void - { - if (null !== $this->dispatcher) { - $this->dispatcher->dispatch(new MessageEvent($message)); - } - } - - public function __toString(): string - { - return 'null'; - } - - public function write(TranslatorBag $translations, bool $override = false): void - { - // TODO: Implement write() method. - } - - public function read(array $domains, array $locales): TranslatorBag - { - // TODO: Implement read() method. - } - - public function delete(TranslatorBag $translations): void - { - // TODO: Implement delete() method. - } -} diff --git a/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php b/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php deleted file mode 100644 index 52fac729a2263..0000000000000 --- a/src/Symfony/Component/Translation/Remote/NullRemoteFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Translation\Remote; - -use Symfony\Component\Translation\Exception\UnsupportedSchemeException; - -final class NullRemoteFactory extends AbstractRemoteFactory -{ - /** - * @return NullRemote - */ - public function create(Dsn $dsn): RemoteInterface - { - if ('null' === $dsn->getScheme()) { - return new NullRemote($this->dispatcher); - } - - throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes()); - } - - protected function getSupportedSchemes(): array - { - return ['null']; - } -} diff --git a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php index 20ea1a15bb33f..bb249d0120051 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php +++ b/src/Symfony/Component/Translation/Remote/RemoteDecorator.php @@ -39,7 +39,7 @@ public function write(TranslatorBag $translations, bool $override = false): void */ public function read(array $domains, array $locales): TranslatorBag { - $domains = empty($this->domains) ? $domains : array_intersect($this->domains, $domains); + $domains = $this->domains ? $domains : array_intersect($this->domains, $domains); $locales = array_intersect($this->locales, $locales); return $this->remote->read($domains, $locales); diff --git a/src/Symfony/Component/Translation/Remote/RemoteInterface.php b/src/Symfony/Component/Translation/Remote/RemoteInterface.php index 043dbcfd7e021..dab6c82558125 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteInterface.php +++ b/src/Symfony/Component/Translation/Remote/RemoteInterface.php @@ -14,27 +14,27 @@ use Symfony\Component\Translation\TranslatorBag; /** - * Remote is used to sync translations with a remote. + * Providers are used to sync translations with a translation provider. */ interface RemoteInterface { /** - * Write given translation to the remote. + * Writes given translation to the provider. * * * Translations available in the MessageCatalogue only must be created. - * * Translations available in both the MessageCatalogue and on the remote + * * Translations available in both the MessageCatalogue and on the provider * must be overwritten. * * Translations available on the remote only must be kept. */ public function write(TranslatorBag $translations, bool $override = false): void; /** - * This method must return asked translations. + * Returns asked translations. */ public function read(array $domains, array $locales): TranslatorBag; /** - * This method must delete all translation given in the TranslatorBag. + * Delete all translation given in the TranslatorBag. */ public function delete(TranslatorBag $translations): void; } diff --git a/src/Symfony/Component/Translation/Remotes.php b/src/Symfony/Component/Translation/Remotes.php index 14862534c7f7f..ccc650be795b4 100644 --- a/src/Symfony/Component/Translation/Remotes.php +++ b/src/Symfony/Component/Translation/Remotes.php @@ -14,10 +14,7 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException; use Symfony\Component\Translation\Remote\RemoteInterface; -/** - * @final - */ -class Remotes +final class Remotes { private $remotes; diff --git a/src/Symfony/Component/Translation/RemotesFactory.php b/src/Symfony/Component/Translation/RemotesFactory.php index 14a38feaf597f..213f32695497e 100644 --- a/src/Symfony/Component/Translation/RemotesFactory.php +++ b/src/Symfony/Component/Translation/RemotesFactory.php @@ -13,18 +13,12 @@ use Symfony\Component\Translation\Exception\UnsupportedSchemeException; use Symfony\Component\Translation\Remote\Dsn; -use Symfony\Component\Translation\Remote\NullRemoteFactory; use Symfony\Component\Translation\Remote\RemoteDecorator; use Symfony\Component\Translation\Remote\RemoteFactoryInterface; use Symfony\Component\Translation\Remote\RemoteInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; class RemotesFactory { - private const FACTORY_CLASSES = [ - LocoRemoteFactory::class, - ]; - private $factories; private $enabledLocales; @@ -43,8 +37,8 @@ public function fromConfig(array $config): Remotes foreach ($config as $name => $currentConfig) { $remotes[$name] = $this->fromString( $currentConfig['dsn'], - empty($currentConfig['locales']) ? $this->enabledLocales : $currentConfig['locales'], - empty($currentConfig['domains']) ? [] : $currentConfig['domains'] + !$currentConfig['locales'] ? $this->enabledLocales : $currentConfig['locales'], + !$currentConfig['domains'] ? [] : $currentConfig['domains'] ); } @@ -66,18 +60,4 @@ public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): Re throw new UnsupportedSchemeException($dsn); } - - /** - * @return RemoteFactoryInterface[] - */ - private static function getDefaultFactories(HttpClientInterface $client = null): iterable - { - foreach (self::FACTORY_CLASSES as $factoryClass) { - if (class_exists($factoryClass)) { - yield new $factoryClass($client); - } - } - - yield new NullRemoteFactory($client); - } } From b4ad50707270474480b8eb0f95d0c1646694bfcb Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Thu, 24 Sep 2020 14:47:45 +0200 Subject: [PATCH 09/14] Rename Remote to Provider --- .../Command/TranslationPullCommand.php | 48 ++++++++-------- .../Command/TranslationPushCommand.php | 54 +++++++++--------- .../Compiler/UnusedTagsPass.php | 2 +- .../DependencyInjection/Configuration.php | 4 +- .../FrameworkExtension.php | 25 +++++---- .../Resources/config/console.php | 4 +- .../Resources/config/translation.php | 14 ++--- ..._remotes.php => translation_providers.php} | 20 +++---- .../Command/TranslationPullCommandTest.php | 6 +- .../Command/TranslationPushCommandTest.php | 36 ++++++------ .../DependencyInjection/ConfigurationTest.php | 2 +- ...{CrowdinRemote.php => CrowdinProvider.php} | 4 +- ...Factory.php => CrowdinProviderFactory.php} | 14 ++--- .../Loco/{LocoRemote.php => LocoProvider.php} | 4 +- ...oteFactory.php => LocoProviderFactory.php} | 14 ++--- .../Exception/IncompleteDsnException.php | 2 +- .../Exception/UnsupportedSchemeException.php | 2 +- .../AbstractProvider.php} | 4 +- .../AbstractProviderFactory.php} | 4 +- .../Translation/{Remote => Provider}/Dsn.php | 2 +- .../ProviderDecorator.php} | 16 +++--- .../ProviderFactoryInterface.php} | 6 +- .../ProviderInterface.php} | 6 +- ...emotesFactory.php => ProvidersFactory.php} | 26 ++++----- src/Symfony/Component/Translation/Remotes.php | 55 ------------------- .../Translation/TranslationProviders.php | 55 +++++++++++++++++++ 26 files changed, 215 insertions(+), 214 deletions(-) rename src/Symfony/Bundle/FrameworkBundle/Resources/config/{translation_remotes.php => translation_providers.php} (56%) rename src/Symfony/Component/Translation/Bridge/Crowdin/{CrowdinRemote.php => CrowdinProvider.php} (95%) rename src/Symfony/Component/Translation/Bridge/Crowdin/{CrowdinRemoteFactory.php => CrowdinProviderFactory.php} (62%) rename src/Symfony/Component/Translation/Bridge/Loco/{LocoRemote.php => LocoProvider.php} (98%) rename src/Symfony/Component/Translation/Bridge/Loco/{LocoRemoteFactory.php => LocoProviderFactory.php} (62%) rename src/Symfony/Component/Translation/{Remote/AbstractRemote.php => Provider/AbstractProvider.php} (93%) rename src/Symfony/Component/Translation/{Remote/AbstractRemoteFactory.php => Provider/AbstractProviderFactory.php} (93%) rename src/Symfony/Component/Translation/{Remote => Provider}/Dsn.php (98%) rename src/Symfony/Component/Translation/{Remote/RemoteDecorator.php => Provider/ProviderDecorator.php} (68%) rename src/Symfony/Component/Translation/{Remote/RemoteFactoryInterface.php => Provider/ProviderFactoryInterface.php} (78%) rename src/Symfony/Component/Translation/{Remote/RemoteInterface.php => Provider/ProviderInterface.php} (87%) rename src/Symfony/Component/Translation/{RemotesFactory.php => ProvidersFactory.php} (66%) delete mode 100644 src/Symfony/Component/Translation/Remotes.php create mode 100644 src/Symfony/Component/Translation/TranslationProviders.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index 312dde0080466..33635f61effcb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -20,7 +20,7 @@ use Symfony\Component\Translation\Catalogue\TargetOperation; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Reader\TranslationReaderInterface; -use Symfony\Component\Translation\Remotes; +use Symfony\Component\Translation\TranslationProviders; use Symfony\Component\Translation\Writer\TranslationWriterInterface; final class TranslationPullCommand extends Command @@ -29,16 +29,16 @@ final class TranslationPullCommand extends Command protected static $defaultName = 'translation:pull'; - private $remotes; + private $providers; private $writer; private $reader; private $defaultLocale; private $transPaths; private $enabledLocales; - public function __construct(Remotes $remotes, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) + public function __construct(TranslationProviders $providers, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) { - $this->remotes = $remotes; + $this->providers = $providers; $this->writer = $writer; $this->defaultLocale = $defaultLocale; $this->transPaths = $transPaths; @@ -56,39 +56,39 @@ public function __construct(Remotes $remotes, TranslationWriterInterface $writer */ protected function configure() { - $keys = $this->remotes->keys(); - $defaultRemote = 1 === \count($keys) ? $keys[0] : null; + $keys = $this->providers->keys(); + $defaultProvider = 1 === \count($keys) ? $keys[0] : null; $this ->setDefinition([ - new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to pull translations from.', $defaultRemote), - new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with remote ones (it will delete not synchronized messages).'), - new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on remote.'), + new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to pull translations from.', $defaultProvider), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with provider ones (it will delete not synchronized messages).'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available locally but not on provider.'), new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull. (Do not forget +intl-icu suffix if nedded).'), new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to pull.'), new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf'), new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version.', '1.2'), ]) - ->setDescription('Pull translations from a given remote.') + ->setDescription('Pull translations from a given provider.') ->setHelp(<<<'EOF' -The %command.name% pull translations from the given remote. Only +The %command.name% pull translations from the given provider. Only new translations are pulled, existing ones are not overwritten. You can overwrite existing translations: - php %command.full_name% --force remote + php %command.full_name% --force provider -You can remove local translations which are not present on the remote: +You can remove local translations which are not present on the provider: - php %command.full_name% --delete-obsolete remote + php %command.full_name% --delete-obsolete provider Full example: - php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en + php %command.full_name% provider --force --delete-obsolete --domains=messages,validators --locales=en This command will pull all translations linked to domains messages and validators for the locale en. Local translations for the specified domains and locale will -be erased if they're not present on the remote and overwritten if it's the +be erased if they're not present on the provider and overwritten if it's the case. Local translations for others domains and locales will be ignored. EOF ) @@ -102,7 +102,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $remoteStorage = $this->remotes->get($remote = $input->getArgument('remote')); + $providerStorage = $this->providers->get($provider = $input->getArgument('provider')); $locales = $input->getOption('locales') ?: $this->enabledLocales; $domains = $input->getOption('domains'); $force = $input->getOption('force'); @@ -116,14 +116,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $writeOptions['xliff_version'] = $input->getOption('xliff-version'); } - $remoteTranslations = $remoteStorage->read($domains, $locales); + $providerTranslations = $providerStorage->read($domains, $locales); if ($force) { if ($deleteObsolete) { $io->note('The --delete-obsolete option is ineffective with --force'); } - foreach ($remoteTranslations->getCatalogues() as $catalogue) { + foreach ($providerTranslations->getCatalogues() as $catalogue) { $operation = new TargetOperation((new MessageCatalogue($catalogue->getLocale())), $catalogue); $operation->moveMessagesToIntlDomainsIfPossible(); $this->writer->write($operation->getResult(), $input->getOption('output-format'), $writeOptions); @@ -131,7 +131,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success(sprintf( 'Local translations are up to date with %s (for [%s] locale(s), and [%s] domain(s)).', - $remote, + $provider, implode(', ', $locales), implode(', ', $domains) )); @@ -142,7 +142,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); if ($deleteObsolete) { - $obsoleteTranslations = $localTranslations->diff($remoteTranslations); + $obsoleteTranslations = $localTranslations->diff($providerTranslations); $translationsWithoutObsoleteToWrite = $localTranslations->diff($obsoleteTranslations); foreach ($translationsWithoutObsoleteToWrite->getCatalogues() as $catalogue) { @@ -152,15 +152,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success('Obsolete translations are locally removed.'); } - $translationsToWrite = $remoteTranslations->diff($localTranslations); + $translationsToWrite = $providerTranslations->diff($localTranslations); foreach ($translationsToWrite->getCatalogues() as $catalogue) { $this->writer->write($catalogue, $input->getOption('output-format'), $writeOptions); } $io->success(sprintf( - 'New remote translations from %s are written locally (for [%s] locale(s), and [%s] domain(s)).', - $remote, + 'New provider translations from %s are written locally (for [%s] locale(s), and [%s] domain(s)).', + $provider, implode(', ', $locales), implode(', ', $domains) )); diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index 831e395b54be0..858bc0bc2bbb3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -20,7 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Reader\TranslationReaderInterface; -use Symfony\Component\Translation\Remotes; +use Symfony\Component\Translation\TranslationProviders; final class TranslationPushCommand extends Command { @@ -28,15 +28,15 @@ final class TranslationPushCommand extends Command protected static $defaultName = 'translation:push'; - private $remotes; + private $providers; private $reader; private $transPaths; private $enabledLocales; private $arrayLoader; - public function __construct(Remotes $remotes, TranslationReaderInterface $reader, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) + public function __construct(TranslationProviders $providers, TranslationReaderInterface $reader, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) { - $this->remotes = $remotes; + $this->providers = $providers; $this->reader = $reader; $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; @@ -54,40 +54,40 @@ public function __construct(Remotes $remotes, TranslationReaderInterface $reader */ protected function configure() { - $keys = $this->remotes->keys(); - $defaultRemote = 1 === \count($keys) ? $keys[0] : null; + $keys = $this->providers->keys(); + $defaultProvider = 1 === \count($keys) ? $keys[0] : null; $this ->setDefinition([ - new InputArgument('remote', null !== $defaultRemote ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The remote to push translations to.', $defaultRemote), + new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to push translations to.', $defaultProvider), new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with local ones (it will delete not synchronized messages).'), - new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available on remote but not locally.'), + new InputOption('delete-obsolete', null, InputOption::VALUE_NONE, 'Delete translations available on provider but not locally.'), new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to push.'), new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to push.', $this->enabledLocales), new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf'), new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version.', '1.2'), ]) - ->setDescription('Push translations to a given remote.') + ->setDescription('Push translations to a given provider.') ->setHelp(<<<'EOF' -The %command.name% push translations to the given remote. Only new +The %command.name% push translations to the given provider. Only new translations are pushed, existing ones are not overwritten. You can overwrite existing translations: - php %command.full_name% --force remote + php %command.full_name% --force provider -You can delete remote translations which are not present locally: +You can delete provider translations which are not present locally: - php %command.full_name% --delete-obsolete remote + php %command.full_name% --delete-obsolete provider Full example: - php %command.full_name% remote --force --delete-obsolete --domains=messages,validators --locales=en + php %command.full_name% provider --force --delete-obsolete --domains=messages,validators --locales=en This command will push all translations linked to domains messages and validators -for the locale en. Remote translations for the specified domains and locale will +for the locale en. Provider translations for the specified domains and locale will be erased if they're not present locally and overwritten if it's the -case. Remote translations for others domains and locales will be ignored. +case. Provider translations for others domains and locales will be ignored. EOF ) ; @@ -99,12 +99,12 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output): int { if (empty($this->enabledLocales)) { - throw new InvalidArgumentException('You must defined framework.translator.enabled_locales config key in order to work with remotes.'); + throw new InvalidArgumentException('You must defined framework.translator.enabled_locales config key in order to work with providers.'); } $io = new SymfonyStyle($input, $output); - $remoteStorage = $this->remotes->get($remote = $input->getArgument('remote')); + $providerStorage = $this->providers->get($provider = $input->getArgument('provider')); $domains = $input->getOption('domains'); $locales = $input->getOption('locales'); $force = $input->getOption('force'); @@ -117,37 +117,37 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (!$deleteObsolete && $force) { - $remoteStorage->write($localTranslations); + $providerStorage->write($localTranslations); return 0; } - $remoteTranslations = $remoteStorage->read($domains, $locales); + $providerTranslations = $providerStorage->read($domains, $locales); if ($deleteObsolete) { - $obsoleteMessages = $remoteTranslations->diff($localTranslations); - $remoteStorage->delete($obsoleteMessages); + $obsoleteMessages = $providerTranslations->diff($localTranslations); + $providerStorage->delete($obsoleteMessages); $io->success(sprintf( 'Obsolete translations on %s are deleted (for [%s] locale(s), and [%s] domain(s)).', - $remote, + $provider, implode(', ', $locales), implode(', ', $domains) )); } - $translationsToWrite = $localTranslations->diff($remoteTranslations); + $translationsToWrite = $localTranslations->diff($providerTranslations); if ($force) { - $translationsToWrite->addBag($localTranslations->intersect($remoteTranslations)); + $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); } - $remoteStorage->write($translationsToWrite); + $providerStorage->write($translationsToWrite); $io->success(sprintf( '%s local translations are sent to %s (for [%s] locale(s), and [%s] domain(s)).', $force ? 'All' : 'New', - $remote, + $provider, implode(', ', $locales), implode(', ', $domains) )); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 279c7f3ece60b..bc6df0824633e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -81,7 +81,7 @@ class UnusedTagsPass implements CompilerPassInterface 'translation.dumper', 'translation.extractor', 'translation.loader', - 'translation.remote_factory', + 'translation.provider_factory', 'twig.extension', 'twig.loader', 'twig.runtime', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index a709b0ca9a190..b4806e6e1ee12 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -803,8 +803,8 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() - ->arrayNode('remotes') - ->info('Remotes you can pull/push your translations from') + ->arrayNode('providers') + ->info('TranslationProviders you can pull/push your translations from') ->useAttributeAsKey('name') ->prototype('array') ->children() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 96336b9b90fa8..76469c982eb1b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -141,7 +141,7 @@ use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; -use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; +use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1143,7 +1143,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder } $loader->load('translation.php'); - $loader->load('translation_remotes.php'); + $loader->load('translation_providers.php'); // Use the "real" translator instead of the identity default $container->setAlias('translator', 'translator.default')->setPublic(true); @@ -1204,9 +1204,9 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->getDefinition('console.command.translation_update')->replaceArgument(6, $transPaths); } - if ($config['remotes']) { + if ($config['providers']) { if (!$config['enabled_locales']) { - throw new LogicException('You must specify framework.translator.enabled_locales in order to use remotes.'); + throw new LogicException('You must specify framework.translator.enabled_locales in order to use providers.'); } if ($container->hasDefinition('console.command.translation_pull')) { @@ -1223,14 +1223,14 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder ; } - $container->getDefinition('translation.remotes_factory') + $container->getDefinition('translation.providers_factory') ->replaceArgument(1, $config['enabled_locales']) ; - $container->getDefinition('translation.remotes')->setArgument(0, $config['remotes']); + $container->getDefinition('translation.providers')->setArgument(0, $config['providers']); $classToServices = [ - LocoRemoteFactory::class => 'translation.remote_factory.loco', + LocoProviderFactory::class => 'translation.provider_factory.loco', ]; foreach ($classToServices as $class => $service) { @@ -1300,9 +1300,10 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder ]); } - if (!empty($config['remotes'])) { + if (!empty($config['providers'])) { if (empty($config['enabled_locales'])) { - throw new LogicException('You must specify framework.translator.enabled_locales in order to use remotes.'); + throw new LogicException('You must specify framework.translator.enabled_locales in order to use providers.'); + throw new LogicException('You must specify framework.translator.enabled_locales in order to use providers.'); } if ($container->hasDefinition('console.command.translation_pull')) { @@ -1319,14 +1320,14 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder ; } - $container->getDefinition('translation.remotes_factory') + $container->getDefinition('translation.providers_factory') ->replaceArgument(1, $config['enabled_locales']) ; - $container->getDefinition('translation.remotes')->setArgument(0, $config['remotes']); + $container->getDefinition('TranslationProviders')->setArgument(0, $config['providers']); $classToServices = [ - LocoRemoteFactory::class => 'translation.remote_factory.loco', + LocoProviderFactory::class => 'translation.provider_factory.loco', ]; foreach ($classToServices as $class => $service) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index cae96e99f4180..f7dbf4ac090eb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -236,7 +236,7 @@ ->set('console.command.translation_pull', TranslationPullCommand::class) ->args([ - service('translation.remotes'), + service('TranslationProviders'), service('translation.writer'), service('translation.reader'), param('kernel.default_locale'), @@ -248,7 +248,7 @@ ->set('console.command.translation_push', TranslationPushCommand::class) ->args([ - service('translation.remotes'), + service('TranslationProviders'), service('translation.reader'), param('translator.default_path'), [], // Translator paths diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index 7c62a278d7797..92f4ea8b51904 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -41,10 +41,10 @@ use Symfony\Component\Translation\Loader\XliffRawLoader; use Symfony\Component\Translation\Loader\YamlFileLoader; use Symfony\Component\Translation\LoggingTranslator; +use Symfony\Component\Translation\ProvidersFactory; use Symfony\Component\Translation\Reader\TranslationReader; use Symfony\Component\Translation\Reader\TranslationReaderInterface; -use Symfony\Component\Translation\Remotes; -use Symfony\Component\Translation\RemotesFactory; +use Symfony\Component\Translation\TranslationProviders; use Symfony\Component\Translation\Writer\TranslationWriter; use Symfony\Component\Translation\Writer\TranslationWriterInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -165,15 +165,15 @@ ->tag('container.service_subscriber', ['id' => 'translator']) ->tag('kernel.cache_warmer') - ->set('translation.remotes', Remotes::class) - ->factory([service('translation.remotes_factory'), 'fromConfig']) + ->set('translation.providers', TranslationProviders::class) + ->factory([service('translation.providers_factory'), 'fromConfig']) ->args([ - [], // Remotes + [], // TranslationProviders ]) - ->set('translation.remotes_factory', RemotesFactory::class) + ->set('translation.providers_factory', ProvidersFactory::class) ->args([ - tagged_iterator('translation.remote_factory'), + tagged_iterator('translation.provider_factory'), [], // Enabled locales ]) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php similarity index 56% rename from src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php rename to src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 5b59ce83606bc..6538686d455fc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_remotes.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -11,13 +11,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Symfony\Component\Translation\Bridge\Crowdin\CrowdinRemoteFactory; -use Symfony\Component\Translation\Bridge\Loco\LocoRemoteFactory; -use Symfony\Component\Translation\Remote\AbstractRemoteFactory; +use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; +use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; return static function (ContainerConfigurator $container) { $container->services() - ->set('translation.remote_factory.abstract', AbstractRemoteFactory::class) + ->set('translation.provider_factory.abstract', AbstractProviderFactory::class) ->args([ service('http_client')->ignoreOnInvalid(), service('translation.loader.xliff_raw'), @@ -26,18 +26,18 @@ ]) ->abstract() - ->set('translation.remote_factory.loco', LocoRemoteFactory::class) + ->set('translation.provider_factory.loco', LocoProviderFactory::class) ->args([ service('translator.data_collector')->nullOnInvalid(), ]) - ->parent('translation.remote_factory.abstract') - ->tag('translation.remote_factory') + ->parent('translation.provider_factory.abstract') + ->tag('translation.provider_factory') - ->set('translation.remote_factory.crowdin', CrowdinRemoteFactory::class) + ->set('translation.provider_factory.crowdin', CrowdinProviderFactory::class) ->args([ service('translator.data_collector')->nullOnInvalid(), ]) - ->parent('translation.remote_factory.abstract') - ->tag('translation.remote_factory') + ->parent('translation.provider_factory.abstract') + ->tag('translation.provider_factory') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php index 7fbb6c6b1152b..03469a7ba69a0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPullCommandTest.php @@ -86,8 +86,8 @@ function ($path, $catalogue) use ($loadedMessages) { ['xlf', 'yml', 'yaml'] ); - $remotes = $this->getMockBuilder('Symfony\Component\Translation\Remotes')->getMock(); - $remotes + $providers = $this->getMockBuilder('Symfony\Component\Translation\TranslationProviders')->getMock(); + $providers ->expects($this->any()) ->method('keys') ->willReturn( @@ -117,7 +117,7 @@ function ($path, $catalogue) use ($loadedMessages) { ->method('getContainer') ->willReturn($container); - $command = new TranslationPullCommand($remotes, $writer, $loader, 'en', $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); + $command = new TranslationPullCommand($providers, $writer, $loader, 'en', $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); $application = new Application($kernel); $application->add($command); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php index 296e57fe5d7b1..8b3f9b7e3d6fc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationPushCommandTest.php @@ -28,30 +28,30 @@ class TranslationPushCommandTest extends TestCase private $translationDir; /** - * @dataProvider remotesProvider + * @dataProvider providersProvider */ - public function testPushNewMessages($remotes) + public function testPushNewMessages($providers) { $tester = $this->createCommandTester( ['messages' => ['new.foo' => 'newFoo']], ['messages' => ['old.foo' => 'oldFoo']], - $remotes, + $providers, ['en'], ['messages'] ); - foreach ($remotes as $name => $remote) { + foreach ($providers as $name => $provider) { $tester->execute([ 'command' => 'translation:push', - 'remote' => $name, + 'provider' => $name, ]); $this->assertRegExp('/New local translations are sent to/', $tester->getDisplay()); } } - public function remotesProvider() + public function providersProvider() { yield [ - ['loco' => $this->getMockBuilder('Symfony\Component\Translation\Bridge\Loco\LocoRemote')->disableOriginalConstructor()->getMock()], + ['loco' => $this->getMockBuilder('Symfony\Component\Translation\Bridge\Loco\LocoProvider')->disableOriginalConstructor()->getMock()], ]; } @@ -71,7 +71,7 @@ protected function tearDown(): void /** * @return CommandTester */ - private function createCommandTester($remoteMessages = [], $localMessages = [], array $remotes = [], array $locales = [], array $domains = [], HttpKernel\KernelInterface $kernel = null, array $transPaths = []) + private function createCommandTester($providerMessages = [], $localMessages = [], array $providers = [], array $locales = [], array $domains = [], HttpKernel\KernelInterface $kernel = null, array $transPaths = []) { $translator = $this->getMockBuilder('Symfony\Component\Translation\Translator') ->disableOriginalConstructor() @@ -100,21 +100,21 @@ function ($path, $catalogue) use ($localMessages) { ['xlf', 'yml', 'yaml'] ); - $remotesMock = $this->getMockBuilder('Symfony\Component\Translation\Remotes') - ->setConstructorArgs([$remotes]) + $providersMock = $this->getMockBuilder('Symfony\Component\Translation\TranslationProviders') + ->setConstructorArgs([$providers]) ->getMock(); - /** @var MockObject $remote */ - foreach ($remotes as $name => $remote) { - $remote + /** @var MockObject $provider */ + foreach ($providers as $name => $provider) { + $provider ->expects($this->any()) ->method('read') ->willReturnCallback( - function (array $domains, array $locales) use ($remoteMessages) { + function (array $domains, array $locales) use ($providerMessages) { $translatorBag = new TranslatorBag(); foreach ($locales as $locale) { foreach ($domains as $domain) { - $translatorBag->addCatalogue((new MessageCatalogue($locale, $remoteMessages)), $domain); + $translatorBag->addCatalogue((new MessageCatalogue($locale, $providerMessages)), $domain); } } @@ -122,10 +122,10 @@ function (array $domains, array $locales) use ($remoteMessages) { } ); - $remotesMock + $providersMock ->expects($this->once()) ->method('get')->with($name) - ->willReturnReference($remote); + ->willReturnReference($provider); } if (null === $kernel) { @@ -151,7 +151,7 @@ function (array $domains, array $locales) use ($remoteMessages) { ->method('getContainer') ->willReturn($container); - $command = new TranslationPushCommand($remotesMock, $reader, $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); + $command = new TranslationPushCommand($providersMock, $reader, $this->translationDir.'/translations', $transPaths, ['en', 'fr', 'nl']); $application = new Application($kernel); $application->add($command); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 71d87458a16a4..cb53b4cc30878 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -387,7 +387,7 @@ protected static function getBundleDefaultConfig() 'parse_html' => false, 'localizable_html_attributes' => [], ], - 'remotes' => [], + 'providers' => [], ], 'validation' => [ 'enabled' => !class_exists(FullStack::class), diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php similarity index 95% rename from src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php rename to src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index 37f5a1854d38a..e0135a7be1dbf 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\Remote\AbstractRemote; +use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -26,7 +26,7 @@ * * In Crowdin: */ -final class CrowdinRemote extends AbstractRemote +final class CrowdinProvider extends AbstractProvider { protected const HOST = 'api.crowdin.com'; diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php similarity index 62% rename from src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php rename to src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php index 44c0676532209..e7b47b9f33846 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php @@ -12,19 +12,19 @@ namespace Symfony\Component\Translation\Bridge\Crowdin; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; -use Symfony\Component\Translation\Remote\AbstractRemoteFactory; -use Symfony\Component\Translation\Remote\Dsn; -use Symfony\Component\Translation\Remote\RemoteInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; -final class CrowdinRemoteFactory extends AbstractRemoteFactory +final class CrowdinProviderFactory extends AbstractProviderFactory { /** - * @return CrowdinRemote + * @return CrowdinProvider */ - public function create(Dsn $dsn): RemoteInterface + public function create(Dsn $dsn): ProviderInterface { if ('crowdin' === $dsn->getScheme()) { - return (new CrowdinRemote($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + return (new CrowdinProvider($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) ->setPort($dsn->getPort()) ; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php similarity index 98% rename from src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php rename to src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php index 748cb9ebe8b72..550d7d5c43d87 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemote.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Translation\Exception\TransportException; use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\Remote\AbstractRemote; +use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -29,7 +29,7 @@ * assets refers to Symfony's translation keys * translations refers to Symfony's translation messages */ -final class LocoRemote extends AbstractRemote +final class LocoProvider extends AbstractProvider { protected const HOST = 'localise.biz'; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php similarity index 62% rename from src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php rename to src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php index cf7766fb91ffd..6c2209363f7e1 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoRemoteFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php @@ -12,19 +12,19 @@ namespace Symfony\Component\Translation\Bridge\Loco; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; -use Symfony\Component\Translation\Remote\AbstractRemoteFactory; -use Symfony\Component\Translation\Remote\Dsn; -use Symfony\Component\Translation\Remote\RemoteInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; -final class LocoRemoteFactory extends AbstractRemoteFactory +final class LocoProviderFactory extends AbstractProviderFactory { /** - * @return LocoRemote + * @return LocoProvider */ - public function create(Dsn $dsn): RemoteInterface + public function create(Dsn $dsn): ProviderInterface { if ('loco' === $dsn->getScheme()) { - return (new LocoRemote($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + return (new LocoProvider($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) ->setPort($dsn->getPort()) ; diff --git a/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php index edc3e03cfe21b..192de3c657a99 100644 --- a/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php +++ b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php @@ -16,7 +16,7 @@ class IncompleteDsnException extends InvalidArgumentException public function __construct(string $message, string $dsn = null, ?\Throwable $previous = null) { if ($dsn) { - $message = sprintf('Invalid "%s" remote storage DSN: ', $dsn).$message; + $message = sprintf('Invalid "%s" provider DSN: ', $dsn).$message; } parent::__construct($message, 0, $previous); diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php index 0221567248ca3..e389158d429a8 100644 --- a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Translation\Exception; -use Symfony\Component\Translation\Remote\Dsn; +use Symfony\Component\Translation\Provider\Dsn; class UnsupportedSchemeException extends LogicException { diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemote.php b/src/Symfony/Component/Translation/Provider/AbstractProvider.php similarity index 93% rename from src/Symfony/Component/Translation/Remote/AbstractRemote.php rename to src/Symfony/Component/Translation/Provider/AbstractProvider.php index c3002b4abf6cf..3bc8a76d7843b 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemote.php +++ b/src/Symfony/Component/Translation/Provider/AbstractProvider.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Translation\Exception\LogicException; use Symfony\Contracts\HttpClient\HttpClientInterface; -abstract class AbstractRemote implements RemoteInterface +abstract class AbstractProvider implements ProviderInterface { protected const HOST = 'localhost'; diff --git a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php similarity index 93% rename from src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php rename to src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php index f5b8e393dcae9..01ee91a803614 100644 --- a/src/Symfony/Component/Translation/Remote/AbstractRemoteFactory.php +++ b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\IncompleteDsnException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -abstract class AbstractRemoteFactory implements RemoteFactoryInterface +abstract class AbstractProviderFactory implements ProviderFactoryInterface { /** @var HttpClientInterface|null */ protected $client; diff --git a/src/Symfony/Component/Translation/Remote/Dsn.php b/src/Symfony/Component/Translation/Provider/Dsn.php similarity index 98% rename from src/Symfony/Component/Translation/Remote/Dsn.php rename to src/Symfony/Component/Translation/Provider/Dsn.php index 0977f68156101..585c813582832 100644 --- a/src/Symfony/Component/Translation/Remote/Dsn.php +++ b/src/Symfony/Component/Translation/Provider/Dsn.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Symfony\Component\Translation\Exception\InvalidArgumentException; diff --git a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php b/src/Symfony/Component/Translation/Provider/ProviderDecorator.php similarity index 68% rename from src/Symfony/Component/Translation/Remote/RemoteDecorator.php rename to src/Symfony/Component/Translation/Provider/ProviderDecorator.php index bb249d0120051..812347204f140 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteDecorator.php +++ b/src/Symfony/Component/Translation/Provider/ProviderDecorator.php @@ -9,19 +9,19 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Symfony\Component\Translation\TranslatorBag; -class RemoteDecorator implements RemoteInterface +class ProviderDecorator implements ProviderInterface { - private $remote; + private $provider; private $locales; private $domains; - public function __construct(RemoteInterface $remote, array $locales, array $domains = []) + public function __construct(ProviderInterface $provider, array $locales, array $domains = []) { - $this->remote = $remote; + $this->provider = $provider; $this->locales = $locales; $this->domains = $domains; } @@ -31,7 +31,7 @@ public function __construct(RemoteInterface $remote, array $locales, array $doma */ public function write(TranslatorBag $translations, bool $override = false): void { - $this->remote->write($translations, $override); + $this->provider->write($translations, $override); } /** @@ -42,7 +42,7 @@ public function read(array $domains, array $locales): TranslatorBag $domains = $this->domains ? $domains : array_intersect($this->domains, $domains); $locales = array_intersect($this->locales, $locales); - return $this->remote->read($domains, $locales); + return $this->provider->read($domains, $locales); } /** @@ -50,6 +50,6 @@ public function read(array $domains, array $locales): TranslatorBag */ public function delete(TranslatorBag $translations): void { - $this->remote->delete($translations); + $this->provider->delete($translations); } } diff --git a/src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php b/src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php similarity index 78% rename from src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php rename to src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php index 9edfc0465a980..3fd4494b4a3cf 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteFactoryInterface.php +++ b/src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php @@ -9,18 +9,18 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Symfony\Component\Translation\Exception\IncompleteDsnException; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; -interface RemoteFactoryInterface +interface ProviderFactoryInterface { /** * @throws UnsupportedSchemeException * @throws IncompleteDsnException */ - public function create(Dsn $dsn): RemoteInterface; + public function create(Dsn $dsn): ProviderInterface; public function supports(Dsn $dsn): bool; } diff --git a/src/Symfony/Component/Translation/Remote/RemoteInterface.php b/src/Symfony/Component/Translation/Provider/ProviderInterface.php similarity index 87% rename from src/Symfony/Component/Translation/Remote/RemoteInterface.php rename to src/Symfony/Component/Translation/Provider/ProviderInterface.php index dab6c82558125..1400e26942063 100644 --- a/src/Symfony/Component/Translation/Remote/RemoteInterface.php +++ b/src/Symfony/Component/Translation/Provider/ProviderInterface.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Remote; +namespace Symfony\Component\Translation\Provider; use Symfony\Component\Translation\TranslatorBag; /** * Providers are used to sync translations with a translation provider. */ -interface RemoteInterface +interface ProviderInterface { /** * Writes given translation to the provider. @@ -24,7 +24,7 @@ interface RemoteInterface * * Translations available in the MessageCatalogue only must be created. * * Translations available in both the MessageCatalogue and on the provider * must be overwritten. - * * Translations available on the remote only must be kept. + * * Translations available on the provider only must be kept. */ public function write(TranslatorBag $translations, bool $override = false): void; diff --git a/src/Symfony/Component/Translation/RemotesFactory.php b/src/Symfony/Component/Translation/ProvidersFactory.php similarity index 66% rename from src/Symfony/Component/Translation/RemotesFactory.php rename to src/Symfony/Component/Translation/ProvidersFactory.php index 213f32695497e..cbcd916fe5a2d 100644 --- a/src/Symfony/Component/Translation/RemotesFactory.php +++ b/src/Symfony/Component/Translation/ProvidersFactory.php @@ -12,18 +12,18 @@ namespace Symfony\Component\Translation; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; -use Symfony\Component\Translation\Remote\Dsn; -use Symfony\Component\Translation\Remote\RemoteDecorator; -use Symfony\Component\Translation\Remote\RemoteFactoryInterface; -use Symfony\Component\Translation\Remote\RemoteInterface; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderDecorator; +use Symfony\Component\Translation\Provider\ProviderFactoryInterface; +use Symfony\Component\Translation\Provider\ProviderInterface; -class RemotesFactory +class ProvidersFactory { private $factories; private $enabledLocales; /** - * @param RemoteFactoryInterface[] $factories + * @param ProviderFactoryInterface[] $factories */ public function __construct(iterable $factories, array $enabledLocales) { @@ -31,30 +31,30 @@ public function __construct(iterable $factories, array $enabledLocales) $this->enabledLocales = $enabledLocales; } - public function fromConfig(array $config): Remotes + public function fromConfig(array $config): TranslationProviders { - $remotes = []; + $providers = []; foreach ($config as $name => $currentConfig) { - $remotes[$name] = $this->fromString( + $providers[$name] = $this->fromString( $currentConfig['dsn'], !$currentConfig['locales'] ? $this->enabledLocales : $currentConfig['locales'], !$currentConfig['domains'] ? [] : $currentConfig['domains'] ); } - return new Remotes($remotes); + return new TranslationProviders($providers); } - public function fromString(string $dsn, array $locales, array $domains = []): RemoteInterface + public function fromString(string $dsn, array $locales, array $domains = []): ProviderInterface { return $this->fromDsnObject(Dsn::fromString($dsn), $locales, $domains); } - public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): RemoteInterface + public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): ProviderInterface { foreach ($this->factories as $factory) { if ($factory->supports($dsn)) { - return new RemoteDecorator($factory->create($dsn), $locales, $domains); + return new ProviderDecorator($factory->create($dsn), $locales, $domains); } } diff --git a/src/Symfony/Component/Translation/Remotes.php b/src/Symfony/Component/Translation/Remotes.php deleted file mode 100644 index ccc650be795b4..0000000000000 --- a/src/Symfony/Component/Translation/Remotes.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Translation; - -use Symfony\Component\Translation\Exception\InvalidArgumentException; -use Symfony\Component\Translation\Remote\RemoteInterface; - -final class Remotes -{ - private $remotes; - - /** - * @param RemoteInterface[] $remotes - */ - public function __construct(iterable $remotes) - { - $this->remotes = []; - foreach ($remotes as $name => $remote) { - $this->remotes[$name] = $remote; - } - } - - public function __toString(): string - { - return '['.implode(',', array_keys($this->remotes)).']'; - } - - public function has(string $name): bool - { - return isset($this->remotes[$name]); - } - - public function get(string $name): RemoteInterface - { - if (!$this->has($name)) { - throw new InvalidArgumentException(sprintf('Remote "%s" not found. Available: "%s".', $name, (string) $this)); - } - - return $this->remotes[$name]; - } - - public function keys(): array - { - return array_keys($this->remotes); - } -} diff --git a/src/Symfony/Component/Translation/TranslationProviders.php b/src/Symfony/Component/Translation/TranslationProviders.php new file mode 100644 index 0000000000000..5b5dada07aec8 --- /dev/null +++ b/src/Symfony/Component/Translation/TranslationProviders.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; +use Symfony\Component\Translation\Provider\ProviderInterface; + +final class TranslationProviders +{ + private $providers; + + /** + * @param ProviderInterface[] $providers + */ + public function __construct(iterable $providers) + { + $this->providers = []; + foreach ($providers as $name => $provider) { + $this->providers[$name] = $provider; + } + } + + public function __toString(): string + { + return '['.implode(',', array_keys($this->providers)).']'; + } + + public function has(string $name): bool + { + return isset($this->providers[$name]); + } + + public function get(string $name): ProviderInterface + { + if (!$this->has($name)) { + throw new InvalidArgumentException(sprintf('Provider "%s" not found. Available: "%s".', $name, (string) $this)); + } + + return $this->providers[$name]; + } + + public function keys(): array + { + return array_keys($this->providers); + } +} From 5f866c5862c296a207a0bdb97abaa802813d354f Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Thu, 24 Sep 2020 23:05:18 +0200 Subject: [PATCH 10/14] WIP Crowdin API Client --- .../FrameworkExtension.php | 6 +- .../Resources/config/console.php | 4 +- .../config/translation_providers.php | 8 ++ .../Bridge/Crowdin/CrowdinProvider.php | 96 +++++++++++++++++-- .../Bridge/Crowdin/CrowdinProviderFactory.php | 2 +- .../Translation/Bridge/Loco/LocoProvider.php | 25 ++--- .../Bridge/Phrase/PhraseProvider.php | 67 +++++++++++++ .../Bridge/Phrase/PhraseProviderFactory.php | 40 ++++++++ .../Translation/Provider/AbstractProvider.php | 1 + 9 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php create mode 100644 src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 76469c982eb1b..a76142b619e12 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -141,7 +141,9 @@ use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; +use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; +use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1324,10 +1326,12 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder ->replaceArgument(1, $config['enabled_locales']) ; - $container->getDefinition('TranslationProviders')->setArgument(0, $config['providers']); + $container->getDefinition('translation.providers')->setArgument(0, $config['providers']); $classToServices = [ LocoProviderFactory::class => 'translation.provider_factory.loco', + CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', + PhraseProviderFactory::class => 'translation.provider_factory.phrase', ]; foreach ($classToServices as $class => $service) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index f7dbf4ac090eb..17563dc4b7b24 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -236,7 +236,7 @@ ->set('console.command.translation_pull', TranslationPullCommand::class) ->args([ - service('TranslationProviders'), + service('translation.providers'), service('translation.writer'), service('translation.reader'), param('kernel.default_locale'), @@ -248,7 +248,7 @@ ->set('console.command.translation_push', TranslationPushCommand::class) ->args([ - service('TranslationProviders'), + service('translation.providers'), service('translation.reader'), param('translator.default_path'), [], // Translator paths diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 6538686d455fc..c7addfde5153b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; +use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Provider\AbstractProviderFactory; @@ -39,5 +40,12 @@ ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') + + ->set('translation.provider_factory.phrase', PhraseProviderFactory::class) + ->args([ + service('translator.data_collector')->nullOnInvalid(), + ]) + ->parent('translation.provider_factory.abstract') + ->tag('translation.provider_factory') ; }; diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index e0135a7be1dbf..b479e5f53b3da 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -25,19 +25,22 @@ * @experimental in 5.2 * * In Crowdin: + * Source strings refers to Symfony's translation keys */ final class CrowdinProvider extends AbstractProvider { - protected const HOST = 'api.crowdin.com'; + protected const HOST = 'crowdin.com/api/v2'; - private $apiKey; + private $projectId; + private $token; private $loader; private $logger; private $defaultLocale; - public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) + public function __construct(string $projectId, string $token, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) { - $this->apiKey = $apiKey; + $this->projectId = $projectId; + $this->token = $token; $this->loader = $loader; $this->logger = $logger; $this->defaultLocale = $defaultLocale; @@ -52,18 +55,30 @@ public function __toString(): string public function write(TranslatorBag $translations, bool $override = false): void { - // TODO: Implement write() method. + foreach ($translations->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $domain => $messages) { + $locale = $catalogue->getLocale(); + + // check if domain exists, if not, create it + + foreach ($messages as $id => $message) { + $this->addString($id); + $this->addTranslation($id, $message, $locale); + } + } + } } + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.exports.post + */ public function read(array $domains, array $locales): TranslatorBag { $filter = $domains ? implode(',', $domains) : '*'; $translatorBag = new TranslatorBag(); foreach ($locales as $locale) { - $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ - 'headers' => $this->getDefaultHeaders(), - ]); + $fileId = $this->getFileId(); $responseContent = $response->getContent(false); @@ -83,4 +98,69 @@ public function delete(TranslatorBag $translations): void { // TODO: Implement delete() method. } + + protected function getDefaultHeaders(): array + { + return [ + 'Authorization' => 'Bearer ' . $this->token, + ]; + } + + /** + * This function allows creation of a new translation key. + * + * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.post + */ + private function addString(string $id): void + { + $response = $this->client->request('POST', sprintf('https://%s/projects/%s/strings', $this->getEndpoint(), $this->projectId), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'text' => $id, + 'identifier' => $id, + ], + ]); + + if (Response::HTTP_CONFLICT === $response->getStatusCode()) { + $this->logger->warning(sprintf('Translation key (%s) already exists in Crowdin.', $id), [ + 'id' => $id, + ]); + } elseif (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Crowdin: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + /** + * This function allows translation of a message. + * + * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.post + */ + private function addTranslation(string $id, string $message, string $locale): void + { + $response = $this->client->request('POST', sprintf('https://%s/projects/%s/translations', $this->getEndpoint(), $this->projectId), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'stringId' => $id, + 'languageId' => $locale, + 'text' => $message, + ], + ]); + + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation message "%s" (for key: "%s") to Crowdin: (status code: "%s") "%s".', $message, $id, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + /** + * @todo: Not sure at all of this + */ + private function getFileId(): int + { + $response = $this->client->request('GET', sprintf('https://%s/projects/%s/files', $this->getEndpoint(), $this->projectId), [ + 'headers' => $this->getDefaultHeaders(), + ]); + $files = json_decode($response->getContent()); + + return $files->data[0]->data->id; + } } diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php index e7b47b9f33846..43cdc47889e98 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php @@ -24,7 +24,7 @@ final class CrowdinProviderFactory extends AbstractProviderFactory public function create(Dsn $dsn): ProviderInterface { if ('crowdin' === $dsn->getScheme()) { - return (new CrowdinProvider($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + return (new CrowdinProvider($this->getUser($dsn), $this->getPassword($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) ->setPort($dsn->getPort()) ; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php index 550d7d5c43d87..af83793dfd3f3 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -27,11 +27,11 @@ * In Loco: * tags refers to Symfony's translation domains * assets refers to Symfony's translation keys - * translations refers to Symfony's translation messages + * translations refers to Symfony's translated messages */ final class LocoProvider extends AbstractProvider { - protected const HOST = 'localise.biz'; + protected const HOST = 'localise.biz/api'; /** @var string */ private $apiKey; @@ -92,7 +92,7 @@ public function read(array $domains, array $locales): TranslatorBag $translatorBag = new TranslatorBag(); foreach ($locales as $locale) { - $response = $this->client->request('GET', sprintf('https://%s/api/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ + $response = $this->client->request('GET', sprintf('https://%s/export/locale/%s.xlf?filter=%s', $this->getEndpoint(), $locale, $filter), [ 'headers' => $this->getDefaultHeaders(), ]); @@ -135,13 +135,16 @@ public function delete(TranslatorBag $translations): void protected function getDefaultHeaders(): array { return [ - 'Authorization' => 'Loco '.$this->apiKey, + 'Authorization' => 'Loco ' . $this->apiKey, ]; } + /** + * This function allows creation of a new translation key. + */ private function createAsset(string $id): void { - $response = $this->client->request('POST', sprintf('https://%s/api/assets', $this->getEndpoint()), [ + $response = $this->client->request('POST', sprintf('https://%s/assets', $this->getEndpoint()), [ 'headers' => $this->getDefaultHeaders(), 'body' => [ 'name' => $id, @@ -162,13 +165,13 @@ private function createAsset(string $id): void private function translateAsset(string $id, string $message, string $locale): void { - $response = $this->client->request('POST', sprintf('https://%s/api/translations/%s/%s', $this->getEndpoint(), $id, $locale), [ + $response = $this->client->request('POST', sprintf('https://%s/translations/%s/%s', $this->getEndpoint(), $id, $locale), [ 'headers' => $this->getDefaultHeaders(), 'body' => $message, ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add translation message (for key: "%s") to Loco: "%s".', $id, $response->getContent(false)), $response); + throw new TransportException(sprintf('Unable to add translation message "%s" (for key: "%s") to Loco: "%s".', $message, $id, $response->getContent(false)), $response); } } @@ -180,7 +183,7 @@ private function tagsAssets(array $ids, string $tag): void $this->createTag($tag); } - $response = $this->client->request('POST', sprintf('https://%s/api/tags/%s.json', $this->getEndpoint(), $tag), [ + $response = $this->client->request('POST', sprintf('https://%s/tags/%s.json', $this->getEndpoint(), $tag), [ 'headers' => $this->getDefaultHeaders(), 'body' => $idsAsString, ]); @@ -192,7 +195,7 @@ private function tagsAssets(array $ids, string $tag): void private function createTag(string $tag): void { - $response = $this->client->request('POST', sprintf('https://%s/api/tags.json', $this->getEndpoint()), [ + $response = $this->client->request('POST', sprintf('https://%s/tags.json', $this->getEndpoint()), [ 'headers' => $this->getDefaultHeaders(), 'body' => [ 'name' => $tag, @@ -206,7 +209,7 @@ private function createTag(string $tag): void private function getTags(): array { - $response = $this->client->request('GET', sprintf('https://%s/api/tags.json', $this->getEndpoint()), [ + $response = $this->client->request('GET', sprintf('https://%s/tags.json', $this->getEndpoint()), [ 'headers' => $this->getDefaultHeaders(), ]); @@ -221,7 +224,7 @@ private function getTags(): array private function deleteAsset(string $id): void { - $response = $this->client->request('DELETE', sprintf('https://%s/api/assets/%s.json', $this->getEndpoint(), $id), [ + $response = $this->client->request('DELETE', sprintf('https://%s/assets/%s.json', $this->getEndpoint(), $id), [ 'headers' => $this->getDefaultHeaders(), ]); diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php new file mode 100644 index 0000000000000..39c9430ae98d6 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProvider; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + * + * In Phrase: + */ +final class PhraseProvider extends AbstractProvider +{ + protected const HOST = 'api.phrase.com/v2'; + + private $apiKey; + private $loader; + private $logger; + private $defaultLocale; + + public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) + { + $this->apiKey = $apiKey; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + + parent::__construct($client); + } + + public function __toString(): string + { + return sprintf('phrase://%s', $this->getEndpoint()); + } + + public function write(TranslatorBag $translations, bool $override = false): void + { + // TODO: Implement write() method. + } + + public function read(array $domains, array $locales): TranslatorBag + { + // TODO: Implement read() method. + } + + public function delete(TranslatorBag $translations): void + { + // TODO: Implement delete() method. + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php new file mode 100644 index 0000000000000..28d7d500bf745 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase; + +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; + +final class PhraseProviderFactory extends AbstractProviderFactory +{ + /** + * @return PhraseProvider + */ + public function create(Dsn $dsn): ProviderInterface + { + if ('phrase' === $dsn->getScheme()) { + return (new PhraseProvider($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) + ->setPort($dsn->getPort()) + ; + } + + throw new UnsupportedSchemeException($dsn, 'phrase', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['phrase']; + } +} diff --git a/src/Symfony/Component/Translation/Provider/AbstractProvider.php b/src/Symfony/Component/Translation/Provider/AbstractProvider.php index 3bc8a76d7843b..5a61db7db25a0 100644 --- a/src/Symfony/Component/Translation/Provider/AbstractProvider.php +++ b/src/Symfony/Component/Translation/Provider/AbstractProvider.php @@ -26,6 +26,7 @@ abstract class AbstractProvider implements ProviderInterface public function __construct(HttpClientInterface $client = null) { $this->client = $client; + if (null === $client) { if (!class_exists(HttpClient::class)) { throw new LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); From 5cc43792e82b3811c73f17b40a66ecbc610662c6 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 25 Sep 2020 13:48:52 +0200 Subject: [PATCH 11/14] POEditor Provider --- .../Command/TranslationPullCommand.php | 11 +- .../Command/TranslationPushCommand.php | 15 +- .../FrameworkExtension.php | 5 + .../Resources/config/translation.php | 4 + .../config/translation_providers.php | 15 +- .../Bridge/Crowdin/CrowdinProviderFactory.php | 13 + .../Bridge/Loco/LocoProviderFactory.php | 13 + .../Bridge/Phrase/PhraseProviderFactory.php | 13 + .../Bridge/PoEditor/PoEditorProvider.php | 222 ++++++++++++++++++ .../PoEditor/PoEditorProviderFactory.php | 53 +++++ .../Provider/AbstractProviderFactory.php | 7 +- 11 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index 33635f61effcb..10530e2772fb4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -40,6 +40,7 @@ public function __construct(TranslationProviders $providers, TranslationWriterIn { $this->providers = $providers; $this->writer = $writer; + $this->reader = $reader; $this->defaultLocale = $defaultLocale; $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; @@ -102,7 +103,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $providerStorage = $this->providers->get($provider = $input->getArgument('provider')); + $provider = $this->providers->get($input->getArgument('provider')); $locales = $input->getOption('locales') ?: $this->enabledLocales; $domains = $input->getOption('domains'); $force = $input->getOption('force'); @@ -116,7 +117,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $writeOptions['xliff_version'] = $input->getOption('xliff-version'); } - $providerTranslations = $providerStorage->read($domains, $locales); + $providerTranslations = $provider->read($domains, $locales); if ($force) { if ($deleteObsolete) { @@ -131,7 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success(sprintf( 'Local translations are up to date with %s (for [%s] locale(s), and [%s] domain(s)).', - $provider, + $input->getArgument('provider'), implode(', ', $locales), implode(', ', $domains) )); @@ -159,8 +160,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $io->success(sprintf( - 'New provider translations from %s are written locally (for [%s] locale(s), and [%s] domain(s)).', - $provider, + 'New translations from %s are written locally (for [%s] locale(s), and [%s] domain(s)).', + $input->getArgument('provider'), implode(', ', $locales), implode(', ', $domains) )); diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index 858bc0bc2bbb3..537ec0ce9820e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -18,7 +18,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\TranslationProviders; @@ -32,7 +31,6 @@ final class TranslationPushCommand extends Command private $reader; private $transPaths; private $enabledLocales; - private $arrayLoader; public function __construct(TranslationProviders $providers, TranslationReaderInterface $reader, string $defaultTransPath = null, array $transPaths = [], array $enabledLocales = []) { @@ -40,7 +38,6 @@ public function __construct(TranslationProviders $providers, TranslationReaderIn $this->reader = $reader; $this->transPaths = $transPaths; $this->enabledLocales = $enabledLocales; - $this->arrayLoader = new ArrayLoader(); if (null !== $defaultTransPath) { $this->transPaths[] = $defaultTransPath; @@ -104,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); - $providerStorage = $this->providers->get($provider = $input->getArgument('provider')); + $provider = $this->providers->get($provider = $input->getArgument('provider')); $domains = $input->getOption('domains'); $locales = $input->getOption('locales'); $force = $input->getOption('force'); @@ -117,16 +114,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (!$deleteObsolete && $force) { - $providerStorage->write($localTranslations); + $provider->write($localTranslations, true); return 0; } - $providerTranslations = $providerStorage->read($domains, $locales); + $providerTranslations = $provider->read($domains, $locales); if ($deleteObsolete) { $obsoleteMessages = $providerTranslations->diff($localTranslations); - $providerStorage->delete($obsoleteMessages); + $provider->delete($obsoleteMessages); $io->success(sprintf( 'Obsolete translations on %s are deleted (for [%s] locale(s), and [%s] domain(s)).', @@ -142,12 +139,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); } - $providerStorage->write($translationsToWrite); + $provider->write($translationsToWrite); $io->success(sprintf( '%s local translations are sent to %s (for [%s] locale(s), and [%s] domain(s)).', $force ? 'All' : 'New', - $provider, + $input->getArgument('provider'), implode(', ', $locales), implode(', ', $domains) )); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index a76142b619e12..ef42e0e132db6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -144,6 +144,7 @@ use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; +use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1233,6 +1234,9 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $classToServices = [ LocoProviderFactory::class => 'translation.provider_factory.loco', + CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', + PhraseProviderFactory::class => 'translation.provider_factory.phrase', + PoEditorProviderFactory::class => 'translation.provider_factory.poeditor', ]; foreach ($classToServices as $class => $service) { @@ -1332,6 +1336,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder LocoProviderFactory::class => 'translation.provider_factory.loco', CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', PhraseProviderFactory::class => 'translation.provider_factory.phrase', + PoEditorProviderFactory::class => 'translation.provider_factory.poeditor', ]; foreach ($classToServices as $class => $service) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index 92f4ea8b51904..96854e2508f0a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -28,6 +28,7 @@ use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\Extractor\PhpExtractor; use Symfony\Component\Translation\Formatter\MessageFormatter; +use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\CsvFileLoader; use Symfony\Component\Translation\Loader\IcuDatFileLoader; use Symfony\Component\Translation\Loader\IcuResFileLoader; @@ -78,6 +79,9 @@ ->set('translator.formatter.default', MessageFormatter::class) ->args([service('identity_translator')]) + ->set('translation.loader.array', ArrayLoader::class) + ->tag('translation.loader', ['alias' => 'array']) + ->set('translation.loader.php', PhpFileLoader::class) ->tag('translation.loader', ['alias' => 'php']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index c7addfde5153b..85cc24392feb9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -14,6 +14,7 @@ use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; +use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; use Symfony\Component\Translation\Provider\AbstractProviderFactory; return static function (ContainerConfigurator $container) { @@ -21,7 +22,6 @@ ->set('translation.provider_factory.abstract', AbstractProviderFactory::class) ->args([ service('http_client')->ignoreOnInvalid(), - service('translation.loader.xliff_raw'), service('logger')->nullOnInvalid(), param('kernel.default_locale'), ]) @@ -29,21 +29,28 @@ ->set('translation.provider_factory.loco', LocoProviderFactory::class) ->args([ - service('translator.data_collector')->nullOnInvalid(), + service('translation.loader.xliff_raw'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') ->set('translation.provider_factory.crowdin', CrowdinProviderFactory::class) ->args([ - service('translator.data_collector')->nullOnInvalid(), + service('translation.loader.xliff_raw'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') ->set('translation.provider_factory.phrase', PhraseProviderFactory::class) ->args([ - service('translator.data_collector')->nullOnInvalid(), + service('translation.loader.xliff_raw'), + ]) + ->parent('translation.provider_factory.abstract') + ->tag('translation.provider_factory') + + ->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class) + ->args([ + service('translation.loader.array'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php index 43cdc47889e98..3a42a9fde8915 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php @@ -11,13 +11,26 @@ namespace Symfony\Component\Translation\Bridge\Crowdin; +use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProviderFactory; use Symfony\Component\Translation\Provider\Dsn; use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; final class CrowdinProviderFactory extends AbstractProviderFactory { + /** @var LoaderInterface */ + private $loader; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + { + parent::__construct($client, $logger, $defaultLocale); + + $this->loader = $loader; + } + /** * @return CrowdinProvider */ diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php index 6c2209363f7e1..54fe38179ded4 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php @@ -11,13 +11,26 @@ namespace Symfony\Component\Translation\Bridge\Loco; +use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProviderFactory; use Symfony\Component\Translation\Provider\Dsn; use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; final class LocoProviderFactory extends AbstractProviderFactory { + /** @var LoaderInterface */ + private $loader; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + { + parent::__construct($client, $logger, $defaultLocale); + + $this->loader = $loader; + } + /** * @return LocoProvider */ diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php index 28d7d500bf745..08445c149a08c 100644 --- a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php @@ -11,13 +11,26 @@ namespace Symfony\Component\Translation\Bridge\Phrase; +use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProviderFactory; use Symfony\Component\Translation\Provider\Dsn; use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; final class PhraseProviderFactory extends AbstractProviderFactory { + /** @var LoaderInterface */ + private $loader; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + { + parent::__construct($client, $logger, $defaultLocale); + + $this->loader = $loader; + } + /** * @return PhraseProvider */ diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php new file mode 100644 index 0000000000000..8995276e53eac --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\PoEditor; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProvider; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + * + * In POeditor: + */ +final class PoEditorProvider extends AbstractProvider +{ + protected const HOST = 'api.poeditor.com/v2'; + + private $projectId; + private $apiKey; + private $loader; + private $logger; + private $defaultLocale; + + public function __construct(string $projectId, string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) + { + $this->projectId = $projectId; + $this->apiKey = $apiKey; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + + parent::__construct($client); + } + + public function __toString(): string + { + return sprintf('poeditor://%s', $this->getEndpoint()); + } + + public function write(TranslatorBag $translatorBag, bool $override = false): void + { + $terms = $translations = []; + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $domain => $messages) { + $locale = $catalogue->getLocale(); + + foreach ($messages as $id => $message) { + $terms[] = [ + 'term' => $id, + 'reference' => $id, + 'tags' => [$domain] + ]; + $translations[$locale][] = [ + 'term' => $id, + 'translation' => [ + 'content' => $message, + ] + ]; + } + $this->addTerms($terms); + } + if ($override) { + $this->updateTranslations($translations[$catalogue->getLocale()], $catalogue->getLocale()); + } else { + $this->addTranslations($translations[$catalogue->getLocale()], $catalogue->getLocale()); + } + } + } + + public function read(array $domains, array $locales): TranslatorBag + { + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + $exportResponse = $this->client->request('POST', sprintf('https://%s/projects/export', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'language' => $locale, + /* + * POEditor XLF export not support Terms as . + * The source tag is either empty or equals to Default Language Reference translation. + * This is why we have to export in json, parse it, and load it with ArrayLoader. + */ + 'type' => 'key_value_json', + 'filters' => json_encode(['translated']), + 'tags' => json_encode($domains), + ], + ]); + + $exportResponseContent = $exportResponse->getContent(false); + + if (Response::HTTP_OK !== $exportResponse->getStatusCode()) { + throw new TransportException('Unable to read the POEditor response: '.$exportResponseContent, $exportResponse); + } + + $response = $this->client->request('GET', json_decode($exportResponseContent, true)['result']['url'], [ + 'headers' => $this->getDefaultHeaders(), + ]); + + $responseContent = json_decode($response->getContent(), true); + + $content = []; + + foreach ($responseContent as $translation) { + $content += $translation; + } + + if ($responseContent) { + foreach ($domains as $domain) { + $translatorBag->addCatalogue($this->loader->load($content, $locale, $domain)); + } + } + } + + return $translatorBag; + } + + public function delete(TranslatorBag $translations): void + { + $deletedIds = $termsToDelete = []; + + foreach ($translations->all() as $locale => $domainMessages) { + foreach ($domainMessages as $domain => $messages) { + foreach ($messages as $id => $message) { + if (\in_array($id, $deletedIds)) { + continue; + } + + $deletedIds = $id; + $termsToDelete = [ + 'term' => $id, + ]; + } + } + } + + $this->deleteTerms($termsToDelete); + } + + private function addTerms(array $terms): void + { + $response = $this->client->request('POST', sprintf('https://%s/terms/add', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'data' => json_encode($terms), + ], + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to POEditor: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + private function addTranslations(array $translations, string $locale): void + { + $response = $this->client->request('POST', sprintf('https://%s/translations/add', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'language' => $locale, + 'data' => json_encode($translations) + ], + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); + } + } + + private function updateTranslations(array $translations, string $locale): void + { + $response = $this->client->request('POST', sprintf('https://%s/languages/update', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'language' => $locale, + 'data' => json_encode($translations) + ], + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); + } + } + + private function deleteTerms(array $ids): void + { + $response = $this->client->request('POST', sprintf('https://%s/terms/delete', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'data' => json_encode($ids) + ], + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to delete translation keys on POEditor: "%s".', $response->getContent(false)), $response); + } + } +} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php new file mode 100644 index 0000000000000..242a9cf147cae --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\PoEditor; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class PoEditorProviderFactory extends AbstractProviderFactory +{ + /** @var LoaderInterface */ + private $loader; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + { + parent::__construct($client, $logger, $defaultLocale); + + $this->loader = $loader; + } + + /** + * @return PhraseProvider + */ + public function create(Dsn $dsn): ProviderInterface + { + if ('poeditor' === $dsn->getScheme()) { + return (new PoEditorProvider($this->getUser($dsn), $this->getPassword($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) + ->setPort($dsn->getPort()) + ; + } + + throw new UnsupportedSchemeException($dsn, 'poeditor', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['poeditor']; + } +} diff --git a/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php index 01ee91a803614..a062fe7005d81 100644 --- a/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php +++ b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php @@ -13,7 +13,6 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Exception\IncompleteDsnException; -use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; abstract class AbstractProviderFactory implements ProviderFactoryInterface @@ -21,19 +20,15 @@ abstract class AbstractProviderFactory implements ProviderFactoryInterface /** @var HttpClientInterface|null */ protected $client; - /** @var LoaderInterface|null */ - protected $loader; - /** @var LoggerInterface|null */ protected $logger; /** @var string|null */ protected $defaultLocale; - public function __construct(HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null) { $this->client = $client; - $this->loader = $loader; $this->logger = $logger; $this->defaultLocale = $defaultLocale; } From 26050f6b8c916000e869e85ef28823a5785c902d Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 25 Sep 2020 17:09:51 +0200 Subject: [PATCH 12/14] Modified POEditor Provider to use xlf export --- .../Resources/config/translation.php | 3 --- .../config/translation_providers.php | 2 +- .../Bridge/PoEditor/PoEditorProvider.php | 21 +++---------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index 96854e2508f0a..a9556b8fe5496 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -79,9 +79,6 @@ ->set('translator.formatter.default', MessageFormatter::class) ->args([service('identity_translator')]) - ->set('translation.loader.array', ArrayLoader::class) - ->tag('translation.loader', ['alias' => 'array']) - ->set('translation.loader.php', PhpFileLoader::class) ->tag('translation.loader', ['alias' => 'php']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 85cc24392feb9..2743844add480 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -50,7 +50,7 @@ ->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class) ->args([ - service('translation.loader.array'), + service('translation.loader.xliff_raw'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php index 8995276e53eac..5e93f51787931 100644 --- a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php @@ -93,12 +93,7 @@ public function read(array $domains, array $locales): TranslatorBag 'api_token' => $this->apiKey, 'id' => $this->projectId, 'language' => $locale, - /* - * POEditor XLF export not support Terms as . - * The source tag is either empty or equals to Default Language Reference translation. - * This is why we have to export in json, parse it, and load it with ArrayLoader. - */ - 'type' => 'key_value_json', + 'type' => 'xlf', 'filters' => json_encode(['translated']), 'tags' => json_encode($domains), ], @@ -114,18 +109,8 @@ public function read(array $domains, array $locales): TranslatorBag 'headers' => $this->getDefaultHeaders(), ]); - $responseContent = json_decode($response->getContent(), true); - - $content = []; - - foreach ($responseContent as $translation) { - $content += $translation; - } - - if ($responseContent) { - foreach ($domains as $domain) { - $translatorBag->addCatalogue($this->loader->load($content, $locale, $domain)); - } + foreach ($domains as $domain) { + $translatorBag->addCatalogue($this->loader->load($response->getContent(), $locale, $domain)); } } From db959f6336b0792a0b70747c9ea132cdd939c714 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Mon, 28 Sep 2020 12:26:28 +0200 Subject: [PATCH 13/14] Transifex Provider in progress --- .../Command/TranslationPushCommand.php | 2 +- .../FrameworkExtension.php | 6 +- .../config/translation_providers.php | 6 +- .../Bridge/Crowdin/CrowdinProvider.php | 5 + .../Translation/Bridge/Loco/LocoProvider.php | 13 +- .../Bridge/Phrase/PhraseProvider.php | 67 ------ .../Bridge/PoEditor/PoEditorProvider.php | 8 + .../Bridge/Transifex/TransifexProvider.php | 201 ++++++++++++++++++ .../TransifexProviderFactory.php} | 21 +- .../Provider/ProviderDecorator.php | 5 + .../Provider/ProviderInterface.php | 5 + .../Component/Translation/TranslatorBag.php | 2 +- 12 files changed, 255 insertions(+), 86 deletions(-) delete mode 100644 src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php create mode 100644 src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php rename src/Symfony/Component/Translation/Bridge/{Phrase/PhraseProviderFactory.php => Transifex/TransifexProviderFactory.php} (63%) diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index 537ec0ce9820e..45df604a70927 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -101,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); - $provider = $this->providers->get($provider = $input->getArgument('provider')); + $provider = $this->providers->get($input->getArgument('provider')); $domains = $input->getOption('domains'); $locales = $input->getOption('locales'); $force = $input->getOption('force'); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index ef42e0e132db6..1eeeb42d98b77 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -143,8 +143,8 @@ use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; -use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; +use Symfony\Component\Translation\Bridge\Transifex\TransifexProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1235,8 +1235,8 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $classToServices = [ LocoProviderFactory::class => 'translation.provider_factory.loco', CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', - PhraseProviderFactory::class => 'translation.provider_factory.phrase', PoEditorProviderFactory::class => 'translation.provider_factory.poeditor', + TransifexProviderFactory::class => 'translation.provider_factory.transifex', ]; foreach ($classToServices as $class => $service) { @@ -1335,8 +1335,8 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $classToServices = [ LocoProviderFactory::class => 'translation.provider_factory.loco', CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', - PhraseProviderFactory::class => 'translation.provider_factory.phrase', PoEditorProviderFactory::class => 'translation.provider_factory.poeditor', + TransifexProviderFactory::class => 'translation.provider_factory.transifex', ]; foreach ($classToServices as $class => $service) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 2743844add480..4385f3b41fee7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -15,6 +15,7 @@ use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; +use Symfony\Component\Translation\Bridge\Transifex\TransifexProviderFactory; use Symfony\Component\Translation\Provider\AbstractProviderFactory; return static function (ContainerConfigurator $container) { @@ -41,16 +42,17 @@ ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') - ->set('translation.provider_factory.phrase', PhraseProviderFactory::class) + ->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class) ->args([ service('translation.loader.xliff_raw'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') - ->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class) + ->set('translation.provider_factory.transifex', TransifexProviderFactory::class) ->args([ service('translation.loader.xliff_raw'), + service('slugger') ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index b479e5f53b3da..3179509806c58 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -53,6 +53,11 @@ public function __toString(): string return sprintf('crowdin://%s', $this->getEndpoint()); } + public function getName(): string + { + return 'crowdin'; + } + public function write(TranslatorBag $translations, bool $override = false): void { foreach ($translations->getCatalogues() as $catalogue) { diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php index af83793dfd3f3..c10b0b9700b58 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -25,8 +25,8 @@ * @experimental in 5.2 * * In Loco: - * tags refers to Symfony's translation domains - * assets refers to Symfony's translation keys + * tags refers to Symfony's translation domains; + * assets refers to Symfony's translation keys; * translations refers to Symfony's translated messages */ final class LocoProvider extends AbstractProvider @@ -60,12 +60,17 @@ public function __toString(): string return sprintf('loco://%s', $this->getEndpoint()); } + public function getName(): string + { + return 'loco'; + } + /** * {@inheritdoc} */ - public function write(TranslatorBag $translations, bool $override = false): void + public function write(TranslatorBag $translatorBag, bool $override = false): void { - foreach ($translations->getCatalogues() as $catalogue) { + foreach ($translatorBag->getCatalogues() as $catalogue) { foreach ($catalogue->all() as $domain => $messages) { $locale = $catalogue->getLocale(); $ids = []; diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php deleted file mode 100644 index 39c9430ae98d6..0000000000000 --- a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php +++ /dev/null @@ -1,67 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Translation\Bridge\Phrase; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\TransportException; -use Symfony\Component\Translation\Loader\LoaderInterface; -use Symfony\Component\Translation\Provider\AbstractProvider; -use Symfony\Component\Translation\TranslatorBag; -use Symfony\Contracts\HttpClient\HttpClientInterface; - -/** - * @author Fabien Potencier - * - * @experimental in 5.2 - * - * In Phrase: - */ -final class PhraseProvider extends AbstractProvider -{ - protected const HOST = 'api.phrase.com/v2'; - - private $apiKey; - private $loader; - private $logger; - private $defaultLocale; - - public function __construct(string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) - { - $this->apiKey = $apiKey; - $this->loader = $loader; - $this->logger = $logger; - $this->defaultLocale = $defaultLocale; - - parent::__construct($client); - } - - public function __toString(): string - { - return sprintf('phrase://%s', $this->getEndpoint()); - } - - public function write(TranslatorBag $translations, bool $override = false): void - { - // TODO: Implement write() method. - } - - public function read(array $domains, array $locales): TranslatorBag - { - // TODO: Implement read() method. - } - - public function delete(TranslatorBag $translations): void - { - // TODO: Implement delete() method. - } -} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php index 5e93f51787931..d3e05d81b1692 100644 --- a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php @@ -25,6 +25,9 @@ * @experimental in 5.2 * * In POeditor: + * Terms refers to Symfony's translation keys; + * Translations refers to Symfony's translated messages; + * tags refers to Symfony's translation domains */ final class PoEditorProvider extends AbstractProvider { @@ -52,6 +55,11 @@ public function __toString(): string return sprintf('poeditor://%s', $this->getEndpoint()); } + public function getName(): string + { + return 'poeditor'; + } + public function write(TranslatorBag $translatorBag, bool $override = false): void { $terms = $translations = []; diff --git a/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php new file mode 100644 index 0000000000000..83d7452d66db6 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php @@ -0,0 +1,201 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Transifex; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; +use Symfony\Component\String\Slugger\AsciiSlugger; +use Symfony\Component\Translation\Exception\TransifexNoResourceException; +use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProvider; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + * + * In Transifex: + * Resource refers to Symfony's translation keys; + * Translations refers to Symfony's translated messages; + * categories refers to Symfony's translation domains + */ +final class TransifexProvider extends AbstractProvider +{ + protected const HOST = 'www.transifex.com/api/2'; + + private $projectSlug; + private $apiKey; + private $loader; + private $logger; + private $defaultLocale; + private $slugger; + + public function __construct(string $projectSlug, string $apiKey, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null, AsciiSlugger $slugger = null) + { + $this->projectSlug = $projectSlug; + $this->apiKey = $apiKey; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->slugger = $slugger; + + parent::__construct($client); + } + + public function __toString(): string + { + return sprintf('transifex://%s', $this->getEndpoint()); + } + + public function getName(): string + { + return 'transifex'; + } + + public function write(TranslatorBag $translatorBag, bool $override = false): void + { + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $domain => $messages) { + $this->ensureProjectExists($domain); + $locale = $catalogue->getLocale(); + + foreach ($messages as $id => $message) { + $this->createResource($id, $domain); + $this->createTranslation($id, $message, $locale, $domain); + } + } + } + } + + public function read(array $domains, array $locales): TranslatorBag + { + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + } + + return $translatorBag; + } + + public function delete(TranslatorBag $translations): void + { + + } + + protected function getDefaultHeaders(): array + { + return [ + 'Authorization' => 'Basic ' . base64_encode('api:' . $this->apiKey), + 'Content-Type' => 'application/json', + ]; + } + + private function createResource(string $id, string $domain): void + { + $response = $this->client->request('GET', sprintf('https://%s/project/%s/resources/', $this->getEndpoint(), $this->getProjectSlug($domain)), [ + 'headers' => $this->getDefaultHeaders(), + ]); + + $resources = array_reduce(json_decode($response->getContent(), true), function($carry, $resource) { + $carry[] = $resource['name']; + + return $carry; + }, []); + + if (in_array($id, $resources)) { + return; + } + + $response = $this->client->request('POST', sprintf('https://%s/project/%s/resources/', $this->getEndpoint(), $this->getProjectSlug($domain)), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => json_encode([ + 'slug' => $id, + 'name' => $id, + 'i18n_type' => 'TXT', + 'accept_translations' => true, + 'content' => $id, + ]), + ]); + + if (Response::HTTP_BAD_REQUEST === $response->getStatusCode()) { + // Translation key already exists in Transifex. + return; + } + + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new translation key (%s) to Transifex: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + private function createTranslation(string $id, string $message, string $locale, string $domain) + { + $response = $this->client->request('PUT', sprintf('https://%s/project/%s/resource/%s/translation/%s/', $this->getEndpoint(), $this->getProjectSlug($domain), $id, $locale), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => json_encode([ + 'content' => $message + ]), + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to translate "%s : %s" in locale "%s" to Transifex: (status code: "%s") "%s".', $id, $message, $locale, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + private function ensureProjectExists(string $domain): void + { + $projectName = $this->getProjectName($domain); + + $response = $this->client->request('GET', sprintf('https://%s/projects', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + ]); + + $projectNames = array_reduce(json_decode($response->getContent(), true), function($carry, $project) { + $carry[] = $project['name']; + + return $carry; + }, []); + + if (in_array($projectName, $projectNames)) { + return; + } + + $response = $this->client->request('POST', sprintf('https://%s/projects', $this->getEndpoint()), [ + 'headers' => $this->getDefaultHeaders(), + 'body' => json_encode([ + 'name' => $projectName, + 'slug' => $this->getProjectSlug($domain), + 'description' => $domain . ' translations domain', + 'source_language_code' => $this->defaultLocale, + 'repository_url' => 'http://github.com/php-translation/symfony' // @todo: only for test purpose, to remove + ]), + ]); + + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to add new project named "%s" to Transifex: (status code: "%s") "%s".', $projectName, $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + private function getProjectName(string $domain): string + { + return $this->projectSlug . '-' . $domain; + } + + private function getProjectSlug(string $domain): string + { + return $this->slugger->slug($this->getProjectName($domain)); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php similarity index 63% rename from src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php rename to src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php index 08445c149a08c..98f9e2d3ca2f8 100644 --- a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php @@ -9,9 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Translation\Bridge\Phrase; +namespace Symfony\Component\Translation\Bridge\Transifex; use Psr\Log\LoggerInterface; +use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProviderFactory; @@ -19,35 +20,39 @@ use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -final class PhraseProviderFactory extends AbstractProviderFactory +final class TransifexProviderFactory extends AbstractProviderFactory { /** @var LoaderInterface */ private $loader; - public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + /** @var AsciiSlugger */ + private $slugger; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null, AsciiSlugger $slugger = null) { parent::__construct($client, $logger, $defaultLocale); $this->loader = $loader; + $this->slugger = $slugger; } /** - * @return PhraseProvider + * @return TransifexProvider */ public function create(Dsn $dsn): ProviderInterface { - if ('phrase' === $dsn->getScheme()) { - return (new PhraseProvider($this->getUser($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + if ('transifex' === $dsn->getScheme()) { + return (new TransifexProvider($this->getUser($dsn), $this->getPassword($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale, $this->slugger)) ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) ->setPort($dsn->getPort()) ; } - throw new UnsupportedSchemeException($dsn, 'phrase', $this->getSupportedSchemes()); + throw new UnsupportedSchemeException($dsn, 'transifex', $this->getSupportedSchemes()); } protected function getSupportedSchemes(): array { - return ['phrase']; + return ['transifex']; } } diff --git a/src/Symfony/Component/Translation/Provider/ProviderDecorator.php b/src/Symfony/Component/Translation/Provider/ProviderDecorator.php index 812347204f140..d97b2f839df4a 100644 --- a/src/Symfony/Component/Translation/Provider/ProviderDecorator.php +++ b/src/Symfony/Component/Translation/Provider/ProviderDecorator.php @@ -26,6 +26,11 @@ public function __construct(ProviderInterface $provider, array $locales, array $ $this->domains = $domains; } + public function getName(): string + { + return $this->provider->getName(); + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Translation/Provider/ProviderInterface.php b/src/Symfony/Component/Translation/Provider/ProviderInterface.php index 1400e26942063..8a5c6cb4ce7ac 100644 --- a/src/Symfony/Component/Translation/Provider/ProviderInterface.php +++ b/src/Symfony/Component/Translation/Provider/ProviderInterface.php @@ -18,6 +18,11 @@ */ interface ProviderInterface { + /** + * Returns the Provider name. + */ + public function getName(): string; + /** * Writes given translation to the provider. * diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php index e8d0509c0b39e..69ed4474628f1 100644 --- a/src/Symfony/Component/Translation/TranslatorBag.php +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -39,7 +39,7 @@ public function getDomains(): array $domains = []; foreach ($this->catalogues as $catalogue) { - $domains += $catalogue->all(); + $domains += $catalogue->getDomains(); } return array_unique($domains); From d88294e90bc45ac847123a75745ec8905b630596 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Mon, 28 Sep 2020 22:04:53 +0200 Subject: [PATCH 14/14] Implemented write method for Crodwin --- .../Command/TranslationPullCommand.php | 5 + .../Command/TranslationPushCommand.php | 5 + .../Resources/config/translation.php | 1 - .../config/translation_providers.php | 4 +- .../HttpClient/Response/MockResponse.php | 2 +- .../Bridge/Crowdin/CrowdinProvider.php | 200 ++++++++++++------ .../Bridge/Crowdin/CrowdinProviderFactory.php | 14 +- .../Translation/Bridge/Loco/LocoProvider.php | 18 +- .../Bridge/Loco/LocoProviderFactory.php | 5 + .../Bridge/PoEditor/PoEditorProvider.php | 22 +- .../PoEditor/PoEditorProviderFactory.php | 5 + .../Bridge/Transifex/TransifexProvider.php | 30 ++- .../Transifex/TransifexProviderFactory.php | 5 + ...ortException.php => ProviderException.php} | 4 +- ...ace.php => ProviderExceptionInterface.php} | 4 +- 15 files changed, 216 insertions(+), 108 deletions(-) rename src/Symfony/Component/Translation/Exception/{TransportException.php => ProviderException.php} (88%) rename src/Symfony/Component/Translation/Exception/{TransportExceptionInterface.php => ProviderExceptionInterface.php} (81%) diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php index 10530e2772fb4..e6cc684a0f76d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPullCommand.php @@ -23,6 +23,11 @@ use Symfony\Component\Translation\TranslationProviders; use Symfony\Component\Translation\Writer\TranslationWriterInterface; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class TranslationPullCommand extends Command { use TranslationTrait; diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php index 45df604a70927..16771ae42dfc7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationPushCommand.php @@ -21,6 +21,11 @@ use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\TranslationProviders; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class TranslationPushCommand extends Command { use TranslationTrait; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index a9556b8fe5496..92f4ea8b51904 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -28,7 +28,6 @@ use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\Extractor\PhpExtractor; use Symfony\Component\Translation\Formatter\MessageFormatter; -use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\CsvFileLoader; use Symfony\Component\Translation\Loader\IcuDatFileLoader; use Symfony\Component\Translation\Loader\IcuResFileLoader; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 4385f3b41fee7..331c39f5e524b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -12,7 +12,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; -use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; use Symfony\Component\Translation\Bridge\Transifex\TransifexProviderFactory; @@ -38,6 +37,7 @@ ->set('translation.provider_factory.crowdin', CrowdinProviderFactory::class) ->args([ service('translation.loader.xliff_raw'), + service('translation.dumper.xliff'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') @@ -52,7 +52,7 @@ ->set('translation.provider_factory.transifex', TransifexProviderFactory::class) ->args([ service('translation.loader.xliff_raw'), - service('slugger') + service('slugger'), ]) ->parent('translation.provider_factory.abstract') ->tag('translation.provider_factory') diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index 1d399d66252fc..45d683b99ba21 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -41,7 +41,7 @@ class MockResponse implements ResponseInterface, StreamableInterface /** * @param string|string[]|iterable $body The response body as a string or an iterable of strings, * yielding an empty string simulates an idle timeout, - * exceptions are turned to TransportException + * exceptions are turned to ProviderException * * @see ResponseInterface::getInfo() for possible info, e.g. "response_headers" */ diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index 3179509806c58..ce879a09af64d 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -13,8 +13,10 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -23,9 +25,6 @@ * @author Fabien Potencier * * @experimental in 5.2 - * - * In Crowdin: - * Source strings refers to Symfony's translation keys */ final class CrowdinProvider extends AbstractProvider { @@ -36,14 +35,17 @@ final class CrowdinProvider extends AbstractProvider private $loader; private $logger; private $defaultLocale; + private $xliffFileDumper; + private $files = []; - public function __construct(string $projectId, string $token, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null) + public function __construct(string $projectId, string $token, HttpClientInterface $client = null, LoaderInterface $loader = null, LoggerInterface $logger = null, string $defaultLocale = null, XliffFileDumper $xliffFileDumper = null) { $this->projectId = $projectId; $this->token = $token; $this->loader = $loader; $this->logger = $logger; $this->defaultLocale = $defaultLocale; + $this->xliffFileDumper = $xliffFileDumper; parent::__construct($client); } @@ -60,15 +62,19 @@ public function getName(): string public function write(TranslatorBag $translations, bool $override = false): void { - foreach ($translations->getCatalogues() as $catalogue) { - foreach ($catalogue->all() as $domain => $messages) { - $locale = $catalogue->getLocale(); - - // check if domain exists, if not, create it - - foreach ($messages as $id => $message) { - $this->addString($id); - $this->addTranslation($id, $message, $locale); + foreach($translations->getDomains() as $domain) { + foreach ($translations->getCatalogues() as $catalogue) { + $content = $this->xliffFileDumper->formatCatalogue($catalogue, $domain); + $fileId = $this->getFileId($domain); + + if ($catalogue->getLocale() === $this->defaultLocale) { + if (!$fileId) { + $this->addFile($domain, $content); + } else { + $this->updateFile($fileId, $domain, $content); + } + } else { + $this->uploadTranslations($fileId, $domain, $content, $catalogue->getLocale()); } } } @@ -79,21 +85,10 @@ public function write(TranslatorBag $translations, bool $override = false): void */ public function read(array $domains, array $locales): TranslatorBag { - $filter = $domains ? implode(',', $domains) : '*'; $translatorBag = new TranslatorBag(); foreach ($locales as $locale) { - $fileId = $this->getFileId(); - - $responseContent = $response->getContent(false); - - if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException('Unable to read the Loco response: '.$responseContent, $response); - } - - foreach ($domains as $domain) { - $translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain)); - } + // TODO: Implement read() method. } return $translatorBag; @@ -107,65 +102,148 @@ public function delete(TranslatorBag $translations): void protected function getDefaultHeaders(): array { return [ - 'Authorization' => 'Bearer ' . $this->token, + 'Authorization' => 'Bearer '.$this->token, ]; } + private function getFileId(string $domain): ?int + { + if (isset($this->files[$domain])) { + return $this->files[$domain]; + } + + try { + $files = $this->getFilesList(); + } catch (ProviderException $e) { + return null; + } + + foreach($files as $file) { + if ($file['data']['name'] === sprintf('%s.%s', $domain, 'xlf')) { + return $this->files[$domain] = (int) $file['data']['id']; + } + } + + return null; + } + /** - * This function allows creation of a new translation key. - * - * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.post + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.post */ - private function addString(string $id): void + private function addFile(string $domain, string $content): void { - $response = $this->client->request('POST', sprintf('https://%s/projects/%s/strings', $this->getEndpoint(), $this->projectId), [ - 'headers' => $this->getDefaultHeaders(), - 'body' => [ - 'text' => $id, - 'identifier' => $id, - ], + $storageId = $this->addStorage($domain, $content); + $response = $this->client->request('POST', sprintf('https://%s/projects/%s/files', $this->getEndpoint(), $this->projectId), [ + 'headers' => array_merge($this->getDefaultHeaders(), [ + 'Content-Type' => 'application/json', + ]), + 'body' => json_encode([ + 'storageId' => $storageId, + 'name' => sprintf('%s.%s', $domain, 'xlf'), + ]), ]); - if (Response::HTTP_CONFLICT === $response->getStatusCode()) { - $this->logger->warning(sprintf('Translation key (%s) already exists in Crowdin.', $id), [ - 'id' => $id, - ]); - } elseif (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Crowdin: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to add a File in Crowdin for domain "%s".', $domain), $response); } + + $this->files[$domain] = (int) json_decode($response->getContent(), true)['data']['id']; } /** - * This function allows translation of a message. - * - * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.post + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.put */ - private function addTranslation(string $id, string $message, string $locale): void + private function updateFile(int $fileId, string $domain, string $content): void { - $response = $this->client->request('POST', sprintf('https://%s/projects/%s/translations', $this->getEndpoint(), $this->projectId), [ - 'headers' => $this->getDefaultHeaders(), - 'body' => [ - 'stringId' => $id, - 'languageId' => $locale, - 'text' => $message, - ], + $storageId = $this->addStorage($domain, $content); + $response = $this->client->request('PUT', sprintf('https://%s/projects/%s/files/%d', $this->getEndpoint(), $this->projectId, $fileId), [ + 'headers' => array_merge($this->getDefaultHeaders(), [ + 'Content-Type' => 'application/json', + ]), + 'body' => json_encode([ + 'storageId' => $storageId, + ]), + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new ProviderException( + sprintf('Unable to update file in Crowdin for file ID "%d" and domain "%s".', $fileId, $domain), + $response + ); + } + } + + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.postOnLanguage + */ + private function uploadTranslations(?int $fileId, string $domain, string $content, string $locale): void + { + if (!$fileId) { + return; + } + + $storageId = $this->addStorage($domain, $content); + $response = $this->client->request('POST', sprintf('https://%s/projects/%s/translations/%s', $this->getEndpoint(), $this->projectId, $locale), [ + 'headers' => array_merge($this->getDefaultHeaders(), [ + 'Content-Type' => 'application/json', + ]), + 'body' => json_encode([ + 'storageId' => $storageId, + 'fileId' => $fileId, + ]), + ]); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new ProviderException( + sprintf('Unable to upload translations to Crowdin for domain "%s" and locale "%s".', $domain, $locale), + $response + ); + } + } + + /** + * @see https://support.crowdin.com/api/v2/#operation/api.storages.post + */ + private function addStorage(string $domain, string $content): int + { + $response = $this->client->request('POST', sprintf('https://%s/storages', $this->getEndpoint()), [ + 'headers' => array_merge($this->getDefaultHeaders(), [ + 'Crowdin-API-FileName' => urlencode(sprintf('%s.%s', $domain, 'xlf')), + 'Content-Type' => 'application/octet-stream', + ]), + 'body' => $content, ]); if (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation message "%s" (for key: "%s") to Crowdin: (status code: "%s") "%s".', $message, $id, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add a Storage in Crowdin for domain "%s".', $domain), $response); } + + $storage = json_decode($response->getContent(), true); + + return $storage['data']['id']; } /** - * @todo: Not sure at all of this + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.getMany */ - private function getFileId(): int + private function getFilesList(): array { - $response = $this->client->request('GET', sprintf('https://%s/projects/%s/files', $this->getEndpoint(), $this->projectId), [ - 'headers' => $this->getDefaultHeaders(), + $response = $this->client->request('GET', sprintf('https://%s/projects/%d/files', $this->getEndpoint(), $this->projectId), [ + 'headers' => array_merge($this->getDefaultHeaders(), [ + 'Content-Type' => 'application/json', + ]), ]); - $files = json_decode($response->getContent()); - return $files->data[0]->data->id; + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new ProviderException('Unable to list Crowdin files.', $response); + } + + $files = json_decode($response->getContent(), true)['data']; + + if (count($files) === 0) { + throw new ProviderException('Crowdin files list is empty.', $response); + } + + return $files; } } diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php index 3a42a9fde8915..02c3f19d31fbf 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Translation\Bridge\Crowdin; use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Dumper\XliffFileDumper; use Symfony\Component\Translation\Exception\UnsupportedSchemeException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProviderFactory; @@ -19,16 +20,25 @@ use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class CrowdinProviderFactory extends AbstractProviderFactory { /** @var LoaderInterface */ private $loader; - public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null) + /** @var XliffFileDumper */ + private $xliffFileDumper; + + public function __construct(HttpClientInterface $client = null, LoggerInterface $logger = null, string $defaultLocale = null, LoaderInterface $loader = null, XliffFileDumper $xliffFileDumper = null) { parent::__construct($client, $logger, $defaultLocale); $this->loader = $loader; + $this->xliffFileDumper = $xliffFileDumper; } /** @@ -37,7 +47,7 @@ public function __construct(HttpClientInterface $client = null, LoggerInterface public function create(Dsn $dsn): ProviderInterface { if ('crowdin' === $dsn->getScheme()) { - return (new CrowdinProvider($this->getUser($dsn), $this->getPassword($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale)) + return (new CrowdinProvider($this->getUser($dsn), $this->getPassword($dsn), $this->client, $this->loader, $this->logger, $this->defaultLocale, $this->xliffFileDumper)) ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) ->setPort($dsn->getPort()) ; diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php index c10b0b9700b58..fe4a3ca32bcd0 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; @@ -104,7 +104,7 @@ public function read(array $domains, array $locales): TranslatorBag $responseContent = $response->getContent(false); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException('Unable to read the Loco response: '.$responseContent, $response); + throw new ProviderException('Unable to read the Loco response: '.$responseContent, $response); } foreach ($domains as $domain) { @@ -140,7 +140,7 @@ public function delete(TranslatorBag $translations): void protected function getDefaultHeaders(): array { return [ - 'Authorization' => 'Loco ' . $this->apiKey, + 'Authorization' => 'Loco '.$this->apiKey, ]; } @@ -164,7 +164,7 @@ private function createAsset(string $id): void 'id' => $id, ]); } elseif (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add new translation key (%s) to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); } } @@ -176,7 +176,7 @@ private function translateAsset(string $id, string $message, string $locale): vo ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add translation message "%s" (for key: "%s") to Loco: "%s".', $message, $id, $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add translation message "%s" (for key: "%s") to Loco: "%s".', $message, $id, $response->getContent(false)), $response); } } @@ -194,7 +194,7 @@ private function tagsAssets(array $ids, string $tag): void ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add tag (%s) on translation keys (%s) to Loco: "%s".', $tag, $idsAsString, $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add tag (%s) on translation keys (%s) to Loco: "%s".', $tag, $idsAsString, $response->getContent(false)), $response); } } @@ -208,7 +208,7 @@ private function createTag(string $tag): void ]); if (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to create tag (%s) on Loco: "%s".', $tag, $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to create tag (%s) on Loco: "%s".', $tag, $response->getContent(false)), $response); } } @@ -221,7 +221,7 @@ private function getTags(): array $content = $response->getContent(false); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to get tags on Loco: "%s".', $content), $response); + throw new ProviderException(sprintf('Unable to get tags on Loco: "%s".', $content), $response); } return json_decode($content); @@ -234,7 +234,7 @@ private function deleteAsset(string $id): void ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Loco: "%s".', $id, $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add new translation key (%s) to Loco: "%s".', $id, $response->getContent(false)), $response); } } } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php index 54fe38179ded4..7786b4696e754 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProviderFactory.php @@ -19,6 +19,11 @@ use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class LocoProviderFactory extends AbstractProviderFactory { /** @var LoaderInterface */ diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php index d3e05d81b1692..305566cdca768 100644 --- a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; @@ -71,13 +71,13 @@ public function write(TranslatorBag $translatorBag, bool $override = false): voi $terms[] = [ 'term' => $id, 'reference' => $id, - 'tags' => [$domain] + 'tags' => [$domain], ]; $translations[$locale][] = [ 'term' => $id, 'translation' => [ 'content' => $message, - ] + ], ]; } $this->addTerms($terms); @@ -110,7 +110,7 @@ public function read(array $domains, array $locales): TranslatorBag $exportResponseContent = $exportResponse->getContent(false); if (Response::HTTP_OK !== $exportResponse->getStatusCode()) { - throw new TransportException('Unable to read the POEditor response: '.$exportResponseContent, $exportResponse); + throw new ProviderException('Unable to read the POEditor response: '.$exportResponseContent, $exportResponse); } $response = $this->client->request('GET', json_decode($exportResponseContent, true)['result']['url'], [ @@ -159,7 +159,7 @@ private function addTerms(array $terms): void ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to POEditor: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add new translation key (%s) to POEditor: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); } } @@ -171,12 +171,12 @@ private function addTranslations(array $translations, string $locale): void 'api_token' => $this->apiKey, 'id' => $this->projectId, 'language' => $locale, - 'data' => json_encode($translations) + 'data' => json_encode($translations), ], ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); } } @@ -188,12 +188,12 @@ private function updateTranslations(array $translations, string $locale): void 'api_token' => $this->apiKey, 'id' => $this->projectId, 'language' => $locale, - 'data' => json_encode($translations) + 'data' => json_encode($translations), ], ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add translation messages to POEditor: "%s".', $response->getContent(false)), $response); } } @@ -204,12 +204,12 @@ private function deleteTerms(array $ids): void 'body' => [ 'api_token' => $this->apiKey, 'id' => $this->projectId, - 'data' => json_encode($ids) + 'data' => json_encode($ids), ], ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to delete translation keys on POEditor: "%s".', $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to delete translation keys on POEditor: "%s".', $response->getContent(false)), $response); } } } diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php index 242a9cf147cae..a27bf92998f21 100644 --- a/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php @@ -19,6 +19,11 @@ use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class PoEditorProviderFactory extends AbstractProviderFactory { /** @var LoaderInterface */ diff --git a/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php index 83d7452d66db6..f5c923f723671 100644 --- a/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProvider.php @@ -13,11 +13,8 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Mime\Part\DataPart; -use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\String\Slugger\AsciiSlugger; -use Symfony\Component\Translation\Exception\TransifexNoResourceException; -use Symfony\Component\Translation\Exception\TransportException; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\AbstractProvider; use Symfony\Component\Translation\TranslatorBag; @@ -93,13 +90,12 @@ public function read(array $domains, array $locales): TranslatorBag public function delete(TranslatorBag $translations): void { - } protected function getDefaultHeaders(): array { return [ - 'Authorization' => 'Basic ' . base64_encode('api:' . $this->apiKey), + 'Authorization' => 'Basic '.base64_encode('api:'.$this->apiKey), 'Content-Type' => 'application/json', ]; } @@ -110,13 +106,13 @@ private function createResource(string $id, string $domain): void 'headers' => $this->getDefaultHeaders(), ]); - $resources = array_reduce(json_decode($response->getContent(), true), function($carry, $resource) { + $resources = array_reduce(json_decode($response->getContent(), true), function ($carry, $resource) { $carry[] = $resource['name']; return $carry; }, []); - if (in_array($id, $resources)) { + if (\in_array($id, $resources)) { return; } @@ -137,7 +133,7 @@ private function createResource(string $id, string $domain): void } if (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new translation key (%s) to Transifex: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add new translation key (%s) to Transifex: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)), $response); } } @@ -146,12 +142,12 @@ private function createTranslation(string $id, string $message, string $locale, $response = $this->client->request('PUT', sprintf('https://%s/project/%s/resource/%s/translation/%s/', $this->getEndpoint(), $this->getProjectSlug($domain), $id, $locale), [ 'headers' => $this->getDefaultHeaders(), 'body' => json_encode([ - 'content' => $message + 'content' => $message, ]), ]); if (Response::HTTP_OK !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to translate "%s : %s" in locale "%s" to Transifex: (status code: "%s") "%s".', $id, $message, $locale, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to translate "%s : "%s"" in locale "%s" to Transifex: (status code: "%s") "%s".', $id, $message, $locale, $response->getStatusCode(), $response->getContent(false)), $response); } } @@ -163,13 +159,13 @@ private function ensureProjectExists(string $domain): void 'headers' => $this->getDefaultHeaders(), ]); - $projectNames = array_reduce(json_decode($response->getContent(), true), function($carry, $project) { + $projectNames = array_reduce(json_decode($response->getContent(), true), function ($carry, $project) { $carry[] = $project['name']; return $carry; }, []); - if (in_array($projectName, $projectNames)) { + if (\in_array($projectName, $projectNames)) { return; } @@ -178,20 +174,20 @@ private function ensureProjectExists(string $domain): void 'body' => json_encode([ 'name' => $projectName, 'slug' => $this->getProjectSlug($domain), - 'description' => $domain . ' translations domain', + 'description' => $domain.' translations domain', 'source_language_code' => $this->defaultLocale, - 'repository_url' => 'http://github.com/php-translation/symfony' // @todo: only for test purpose, to remove + 'repository_url' => 'http://github.com/php-translation/symfony', // @todo: only for test purpose, to remove ]), ]); if (Response::HTTP_CREATED !== $response->getStatusCode()) { - throw new TransportException(sprintf('Unable to add new project named "%s" to Transifex: (status code: "%s") "%s".', $projectName, $response->getStatusCode(), $response->getContent(false)), $response); + throw new ProviderException(sprintf('Unable to add new project named "%s" to Transifex: (status code: "%s") "%s".', $projectName, $response->getStatusCode(), $response->getContent(false)), $response); } } private function getProjectName(string $domain): string { - return $this->projectSlug . '-' . $domain; + return $this->projectSlug.'-'.$domain; } private function getProjectSlug(string $domain): string diff --git a/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php index 98f9e2d3ca2f8..40e5f5c4493f3 100644 --- a/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php +++ b/src/Symfony/Component/Translation/Bridge/Transifex/TransifexProviderFactory.php @@ -20,6 +20,11 @@ use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Fabien Potencier + * + * @experimental in 5.2 + */ final class TransifexProviderFactory extends AbstractProviderFactory { /** @var LoaderInterface */ diff --git a/src/Symfony/Component/Translation/Exception/TransportException.php b/src/Symfony/Component/Translation/Exception/ProviderException.php similarity index 88% rename from src/Symfony/Component/Translation/Exception/TransportException.php rename to src/Symfony/Component/Translation/Exception/ProviderException.php index 7f754617525c4..29947b5d53b43 100644 --- a/src/Symfony/Component/Translation/Exception/TransportException.php +++ b/src/Symfony/Component/Translation/Exception/ProviderException.php @@ -18,10 +18,10 @@ * * @experimental in 5.2 */ -class TransportException extends RuntimeException implements TransportExceptionInterface +class ProviderException extends RuntimeException implements ProviderExceptionInterface { private $response; - private $debug = ''; + private $debug; public function __construct(string $message, ResponseInterface $response, int $code = 0, \Exception $previous = null) { diff --git a/src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php b/src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php similarity index 81% rename from src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php rename to src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php index 00fcb6a1ce049..279b1d779a8e3 100644 --- a/src/Symfony/Component/Translation/Exception/TransportExceptionInterface.php +++ b/src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php @@ -14,9 +14,9 @@ /** * @author Fabien Potencier * - * @experimental in 5.1 + * @experimental in 5.2 */ -interface TransportExceptionInterface extends ExceptionInterface +interface ProviderExceptionInterface extends ExceptionInterface { public function getDebug(): string; }