From 240ac22f705d01fa3dd04fca38b32139f5ec9243 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 23 Apr 2021 15:27:02 +0200 Subject: [PATCH] Added PoEditor Provider --- .../FrameworkExtension.php | 8 +- .../config/translation_providers.php | 10 + .../Translation/Bridge/Crowdin/README.md | 8 +- .../Translation/Bridge/Loco/README.md | 8 +- .../Bridge/PoEditor/.gitattributes | 4 + .../Translation/Bridge/PoEditor/.gitignore | 3 + .../Translation/Bridge/PoEditor/CHANGELOG.md | 7 + .../Translation/Bridge/PoEditor/LICENSE | 19 + .../Bridge/PoEditor/PoEditorProvider.php | 239 ++++++++ .../PoEditor/PoEditorProviderFactory.php | 65 +++ .../Translation/Bridge/PoEditor/README.md | 28 + .../Tests/PoEditorProviderFactoryTest.php | 39 ++ .../PoEditor/Tests/PoEditorProviderTest.php | 514 ++++++++++++++++++ .../Translation/Bridge/PoEditor/composer.json | 33 ++ .../Bridge/PoEditor/phpunit.xml.dist | 31 ++ .../Component/Translation/CHANGELOG.md | 2 + .../Exception/UnsupportedSchemeException.php | 4 + .../Translation/Test/ProviderTestCase.php | 10 + 18 files changed, 1022 insertions(+), 10 deletions(-) create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/.gitattributes create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/.gitignore create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/CHANGELOG.md create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/LICENSE create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.php create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/README.md create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderFactoryTest.php create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderTest.php create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/composer.json create mode 100644 src/Symfony/Component/Translation/Bridge/PoEditor/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 97a9dedb6c..910a083f79 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -171,6 +171,7 @@ 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\PoEditor\PoEditorProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1344,14 +1345,17 @@ class FrameworkExtension extends Extension $classToServices = [ CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', LocoProviderFactory::class => 'translation.provider_factory.loco', + PoEditorProviderFactory::class => 'translation.provider_factory.poeditor', ]; $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; foreach ($classToServices as $class => $service) { - $package = sprintf('symfony/%s-translation-provider', substr($service, \strlen('translation.provider_factory.'))); + switch ($package = substr($service, \strlen('translation.provider_factory.'))) { + case 'poeditor': $package = 'po-editor'; break; + } - if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable($package, $class, $parentPackages)) { + if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable(sprintf('symfony/%s-translation-provider', $package), $class, $parentPackages)) { $container->removeDefinition($service); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 5c55e91a21..3633723579 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; +use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory; use Symfony\Component\Translation\Provider\NullProviderFactory; use Symfony\Component\Translation\Provider\TranslationProviderCollection; use Symfony\Component\Translation\Provider\TranslationProviderCollectionFactory; @@ -52,5 +53,14 @@ return static function (ContainerConfigurator $container) { service('translation.loader.xliff'), ]) ->tag('translation.provider_factory') + + ->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class) + ->args([ + service('http_client'), + service('logger'), + param('kernel.default_locale'), + service('translation.loader.xliff'), + ]) + ->tag('translation.provider_factory') ; }; diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/README.md b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md index e287b89d44..effcf598ad 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/README.md +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md @@ -23,7 +23,7 @@ where: Resources --------- - * [Contributing](https://symfony.com/doc/current/contributing/index.html) - * [Report issues](https://github.com/symfony/symfony/issues) and - [send Pull Requests](https://github.com/symfony/symfony/pulls) - in the [main Symfony repository](https://github.com/symfony/symfony) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + n the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Translation/Bridge/Loco/README.md b/src/Symfony/Component/Translation/Bridge/Loco/README.md index 2624f3329d..d402682c28 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/README.md +++ b/src/Symfony/Component/Translation/Bridge/Loco/README.md @@ -19,7 +19,7 @@ where: Resources --------- - * [Contributing](https://symfony.com/doc/current/contributing/index.html) - * [Report issues](https://github.com/symfony/symfony/issues) and - [send Pull Requests](https://github.com/symfony/symfony/pulls) - in the [main Symfony repository](https://github.com/symfony/symfony) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/.gitattributes b/src/Symfony/Component/Translation/Bridge/PoEditor/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/.gitignore b/src/Symfony/Component/Translation/Bridge/PoEditor/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/PoEditor/CHANGELOG.md new file mode 100644 index 0000000000..bbb9efcaeb --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Create the bridge diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/LICENSE b/src/Symfony/Component/Translation/Bridge/PoEditor/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. 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 0000000000..0dd638089a --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProvider.php @@ -0,0 +1,239 @@ + + * + * 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\ProviderException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + * + * In PoEditor: + * * Terms refer to Symfony's translation keys; + * * Translations refer to Symfony's translated messages; + * * Context fields refer to Symfony's translation domains + * + * PoEditor's API always returns 200 status code, even in case of failure. + * + * @experimental in 5.3 + */ +final class PoEditorProvider implements ProviderInterface +{ + private $apiKey; + private $projectId; + private $client; + private $loader; + private $logger; + private $defaultLocale; + private $endpoint; + + public function __construct(string $apiKey, string $projectId, HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint) + { + $this->apiKey = $apiKey; + $this->projectId = $projectId; + $this->client = $client; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->endpoint = $endpoint; + } + + public function __toString(): string + { + return sprintf('poeditor://%s', $this->endpoint); + } + + public function write(TranslatorBagInterface $translatorBag): void + { + $defaultCatalogue = $translatorBag->getCatalogue($this->defaultLocale); + + if (!$defaultCatalogue) { + $defaultCatalogue = $translatorBag->getCatalogues()[0]; + } + + $terms = $translationsToAdd = []; + foreach ($defaultCatalogue->all() as $domain => $messages) { + foreach ($messages as $id => $message) { + $terms[] = [ + 'term' => $id, + 'reference' => $id, + // tags field is mandatory to export all translations in read method. + 'tags' => [$domain], + 'context' => $domain, + ]; + } + } + $this->addTerms($terms); + + foreach ($translatorBag->getCatalogues() as $catalogue) { + $locale = $catalogue->getLocale(); + foreach ($catalogue->all() as $domain => $messages) { + foreach ($messages as $id => $message) { + $translationsToAdd[$locale][] = [ + 'term' => $id, + 'context' => $domain, + 'translation' => [ + 'content' => $message, + ], + ]; + } + } + } + + $this->addTranslations($translationsToAdd); + } + + public function read(array $domains, array $locales): TranslatorBag + { + $translatorBag = new TranslatorBag(); + $exportResponses = $downloadResponses = []; + + foreach ($locales as $locale) { + foreach ($domains as $domain) { + $exportResponses[] = [ + 'response' => $this->client->request('POST', 'projects/export', [ + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'language' => $locale, + 'type' => 'xlf', + 'filters' => json_encode(['translated']), + 'tags' => json_encode([$domain]), + ], + ]), + 'locale' => $locale, + 'domain' => $domain, + ]; + } + } + + foreach ($exportResponses as $exportResponse) { + $response = $exportResponse['response']; + $responseContent = $response->toArray(false); + + if (200 !== $response->getStatusCode() || '200' !== (string) $responseContent['response']['code']) { + $this->logger->error('Unable to read the PoEditor response: '.$response->getContent(false)); + continue; + } + + $fileUrl = $responseContent['result']['url']; + $downloadResponses[] = [ + 'response' => $this->client->request('GET', $fileUrl), + 'locale' => $exportResponse['locale'], + 'domain' => $exportResponse['domain'], + 'fileUrl' => $fileUrl, + ]; + } + + foreach ($downloadResponses as $downloadResponse) { + $response = $downloadResponse['response']; + $locale = $downloadResponse['locale']; + $domain = $downloadResponse['domain']; + $fileUrl = $downloadResponse['fileUrl']; + $responseContent = $response->getContent(false); + + if (200 !== $response->getStatusCode()) { + $this->logger->error('Unable to download the PoEditor exported file: '.$responseContent); + continue; + } + + if (!$responseContent) { + $this->logger->error(sprintf('The exported file "%s" from PoEditor is empty.', $fileUrl)); + continue; + } + + $translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain)); + } + + return $translatorBag; + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $deletedIds = $termsToDelete = []; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $domain => $messages) { + foreach ($messages as $id => $message) { + if (\array_key_exists($domain, $deletedIds) && \in_array($id, $deletedIds[$domain], true)) { + continue; + } + + $deletedIds[$domain][] = $id; + $termsToDelete[] = [ + 'term' => $id, + 'context' => $domain, + ]; + } + } + } + + $this->deleteTerms($termsToDelete); + } + + private function addTerms(array $terms): void + { + $response = $this->client->request('POST', 'terms/add', [ + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'data' => json_encode($terms), + ], + ]); + + if (200 !== $response->getStatusCode() || '200' !== (string) $response->toArray(false)['response']['code']) { + throw new ProviderException(sprintf('Unable to add new translation keys to PoEditor: (status code: "%s") "%s".', $response->getStatusCode(), $response->getContent(false)), $response); + } + } + + private function addTranslations(array $translationsPerLocale): void + { + $responses = []; + + foreach ($translationsPerLocale as $locale => $translations) { + $responses = $this->client->request('POST', 'translations/add', [ + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'language' => $locale, + 'data' => json_encode($translations), + ], + ]); + } + + foreach ($responses as $response) { + if (200 !== $response->getStatusCode() || '200' !== (string) $response->toArray(false)['response']['code']) { + $this->logger->error(sprintf('Unable to add translation messages to PoEditor: "%s".', $response->getContent(false))); + } + } + } + + private function deleteTerms(array $ids): void + { + $response = $this->client->request('POST', 'terms/delete', [ + 'body' => [ + 'api_token' => $this->apiKey, + 'id' => $this->projectId, + 'data' => json_encode($ids), + ], + ]); + + if (200 !== $response->getStatusCode() || '200' !== (string) $response->toArray(false)['response']['code']) { + 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 new file mode 100644 index 0000000000..16aae3e2fe --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/PoEditorProviderFactory.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\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; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class PoEditorProviderFactory extends AbstractProviderFactory +{ + private const HOST = 'api.poeditor.com'; + + private $client; + private $logger; + private $defaultLocale; + private $loader; + + public function __construct(HttpClientInterface $client, LoggerInterface $logger, string $defaultLocale, LoaderInterface $loader) + { + $this->client = $client; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->loader = $loader; + } + + /** + * @return PoEditorProvider + */ + public function create(Dsn $dsn): ProviderInterface + { + if ('poeditor' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'poeditor', $this->getSupportedSchemes()); + } + + $endpoint = sprintf('%s%s', 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(), $dsn->getPort() ? ':'.$dsn->getPort() : ''); + $client = $this->client->withOptions([ + 'base_uri' => 'https://'.$endpoint.'/v2/', + ]); + + return new PoEditorProvider($this->getPassword($dsn), $this->getUser($dsn), $client, $this->loader, $this->logger, $this->defaultLocale, $endpoint); + } + + protected function getSupportedSchemes(): array + { + return ['poeditor']; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/README.md b/src/Symfony/Component/Translation/Bridge/PoEditor/README.md new file mode 100644 index 0000000000..9622a5d477 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/README.md @@ -0,0 +1,28 @@ +PoEditor Translation Provider +============================= + +Provides PoEditor integration for Symfony Translation. + +DSN example +----------- + +``` +// .env file +POEDITOR_DSN=poeditor://PROJECT_ID:API_KEY@default +``` + +where: + - `PROJECT_ID` is your PoEditor Project ID + - `API_KEY` is your PoEditor API key + +Go to the Project in PoEditor to find the Project ID in the url. + +[Generate an API key on PoEditor](https://poeditor.com/account/api) + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderFactoryTest.php b/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderFactoryTest.php new file mode 100644 index 0000000000..b0e6b5f41f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderFactoryTest.php @@ -0,0 +1,39 @@ +getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader()); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderTest.php b/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderTest.php new file mode 100644 index 0000000000..5c813e4821 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/Tests/PoEditorProviderTest.php @@ -0,0 +1,514 @@ +createProvider($this->getClient()->withOptions([ + 'base_uri' => 'api.poeditor.com', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.poeditor.com'), + 'poeditor://api.poeditor.com', + ]; + + yield [ + $this->createProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://example.com', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com'), + 'poeditor://example.com', + ]; + + yield [ + $this->createProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://example.com:99', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com:99'), + 'poeditor://example.com:99', + ]; + } + + public function testCompleteWriteProcess() + { + $successResponse = new MockResponse(json_encode([ + 'response' => [ + 'status' => 'success', + 'code' => '200', + 'message' => 'OK', + ], + ])); + + $responses = [ + 'addTerms' => function (string $method, string $url, array $options = []) use ($successResponse): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame(http_build_query([ + 'api_token' => 'API_KEY', + 'id' => 'PROJECT_ID', + 'data' => json_encode([ + [ + 'term' => 'a', + 'reference' => 'a', + 'tags' => ['messages'], + 'context' => 'messages', + ], + [ + 'term' => 'post.num_comments', + 'reference' => 'post.num_comments', + 'tags' => ['validators'], + 'context' => 'validators', + ], + ]), + ]), $options['body']); + + return $successResponse; + }, + 'addTranslationsEn' => function (string $method, string $url, array $options = []) use ($successResponse): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame(http_build_query([ + 'api_token' => 'API_KEY', + 'id' => 'PROJECT_ID', + 'language' => 'en', + 'data' => json_encode([ + [ + 'term' => 'a', + 'context' => 'messages', + 'translation' => ['content' => 'trans_en_a'], + ], + [ + 'term' => 'post.num_comments', + 'context' => 'validators', + 'translation' => ['content' => '{count, plural, one {# comment} other {# comments}}'], + ], + ]), + ]), $options['body']); + + return $successResponse; + }, + 'addTranslationsFr' => function (string $method, string $url, array $options = []) use ($successResponse): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame(http_build_query([ + 'api_token' => 'API_KEY', + 'id' => 'PROJECT_ID', + 'language' => 'fr', + 'data' => json_encode([ + [ + 'term' => 'a', + 'context' => 'messages', + 'translation' => ['content' => 'trans_fr_a'], + ], + [ + 'term' => 'post.num_comments', + 'context' => 'validators', + 'translation' => ['content' => '{count, plural, one {# commentaire} other {# commentaires}}'], + ], + ]), + ]), $options['body']); + + return $successResponse; + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}'], + ])); + + $provider = $this->createProvider( + new MockHttpClient($responses, 'https://api.poeditor.com/v2/'), + $this->getLoader(), + $this->getLogger(), + $this->getDefaultLocale(), + 'api.poeditor.com' + ); + + $provider->write($translatorBag); + } + + /** + * @dataProvider getResponsesForOneLocaleAndOneDomain + */ + public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $loader = $this->getLoader(); + $loader->expects($this->once()) + ->method('load') + ->willReturn((new XliffFileLoader())->load($responseContent, $locale, $domain)); + + $responses = [ + new MockResponse(json_encode([ + 'response' => [ + 'status' => 'success', + 'code' => '200', + 'message' => 'OK', + ], + 'result' => [ + 'url' => 'https://api.poeditor.com/v2/download/file/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + ], + ])), + new MockResponse($responseContent), + ]; + + $provider = $this->createProvider( + new MockHttpClient($responses, 'https://api.poeditor.com/v2/'), + $loader, + $this->getLogger(), + $this->getDefaultLocale(), + 'api.poeditor.com' + ); + + $translatorBag = $provider->read([$domain], [$locale]); + // We don't want to assert equality of metadata here, due to the ArrayLoader usage. + foreach ($translatorBag->getCatalogues() as $catalogue) { + $catalogue->deleteMetadata('', ''); + } + + $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + /** + * @dataProvider getResponsesForManyLocalesAndManyDomains + */ + public function testReadForManyLocalesAndManyDomains(array $locales, array $domains, array $responseContents, TranslatorBag $expectedTranslatorBag) + { + $exportResponses = $downloadResponses = []; + $consecutiveLoadArguments = []; + $consecutiveLoadReturns = []; + foreach ($locales as $locale) { + foreach ($domains as $domain) { + $exportResponses[] = new MockResponse(json_encode([ + 'response' => [ + 'status' => 'success', + 'code' => '200', + 'message' => 'OK', + ], + 'result' => [ + 'url' => 'https://api.poeditor.com/v2/download/file/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + ], + ])); + $downloadResponses[] = new MockResponse($responseContents[$locale][$domain]); + $consecutiveLoadArguments[] = [$responseContents[$locale][$domain], $locale, $domain]; + $consecutiveLoadReturns[] = (new XliffFileLoader())->load($responseContents[$locale][$domain], $locale, $domain); + } + } + + $loader = $this->getLoader(); + $loader->expects($this->exactly(\count($consecutiveLoadArguments))) + ->method('load') + ->withConsecutive(...$consecutiveLoadArguments) + ->willReturnOnConsecutiveCalls(...$consecutiveLoadReturns); + + $provider = $this->createProvider( + new MockHttpClient(array_merge($exportResponses, $downloadResponses), 'https://api.poeditor.com/v2/'), + $loader, + $this->getLogger(), + $this->getDefaultLocale(), + 'api.poeditor.com' + ); + + $translatorBag = $provider->read($domains, $locales); + // We don't want to assert equality of metadata here, due to the ArrayLoader usage. + foreach ($translatorBag->getCatalogues() as $catalogue) { + $catalogue->deleteMetadata('', ''); + } + + $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + public function testDeleteProcess() + { + $successResponse = new MockResponse(json_encode([ + 'response' => [ + 'status' => 'success', + 'code' => '200', + 'message' => 'OK', + ], + ])); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}'], + ])); + + $provider = $this->createProvider( + new MockHttpClient(function (string $method, string $url, array $options = []) use ($successResponse): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame(http_build_query([ + 'api_token' => 'API_KEY', + 'id' => 'PROJECT_ID', + 'data' => json_encode([ + [ + 'term' => 'a', + 'context' => 'messages', + ], + [ + 'term' => 'post.num_comments', + 'context' => 'validators', + ], + ]), + ]), $options['body']); + + return $successResponse; + }, 'https://api.poeditor.com/v2/'), + $this->getLoader(), + $this->getLogger(), + $this->getDefaultLocale(), + 'api.poeditor.com' + ); + + $provider->delete($translatorBag); + } + + public function getResponsesForOneLocaleAndOneDomain(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBagEn = new TranslatorBag(); + $expectedTranslatorBagEn->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Hello', + 'index.greetings' => 'Welcome, {firstname}!', + ], 'en')); + + yield ['en', 'messages', <<<'XLIFF' + + + + + + index.hello + Hello + index.hello + + index.hello + + + + + index.greetings + Welcome, {firstname}! + index.greetings + + index.greetings + + + + + + +XLIFF + , + $expectedTranslatorBagEn, + ]; + + $expectedTranslatorBagFr = new TranslatorBag(); + $expectedTranslatorBagFr->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr')); + + yield ['fr', 'messages', <<<'XLIFF' + + + + + + index.hello + Bonjour + index.hello + + index.hello + + + + + index.greetings + Bienvenue, {firstname} ! + index.greetings + + index.greetings + + + + + + +XLIFF + , + $expectedTranslatorBagFr, + ]; + } + + public function getResponsesForManyLocalesAndManyDomains(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBag = new TranslatorBag(); + $expectedTranslatorBag->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Hello', + 'index.greetings' => 'Welcome, {firstname}!', + ], 'en')); + $expectedTranslatorBag->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr')); + $expectedTranslatorBag->addCatalogue($arrayLoader->load([ + 'firstname.error' => 'Firstname must contains only letters.', + 'lastname.error' => 'Lastname must contains only letters.', + ], 'en', 'validators')); + $expectedTranslatorBag->addCatalogue($arrayLoader->load([ + 'firstname.error' => 'Le prénom ne peut contenir que des lettres.', + 'lastname.error' => 'Le nom de famille ne peut contenir que des lettres.', + ], 'fr', 'validators')); + + yield [ + ['en', 'fr'], + ['messages', 'validators'], + [ + 'en' => [ + 'messages' => <<<'XLIFF' + + + + + + index.hello + Hello + index.hello + + index.hello + + + + + index.greetings + Welcome, {firstname}! + index.greetings + + index.greetings + + + + + + +XLIFF + , + 'validators' => <<<'XLIFF' + + + + + + firstname.error + Firstname must contains only letters. + firstname.error + + firstname.error + + + + + lastname.error + Lastname must contains only letters. + lastname.error + + lastname.error + + + + + + +XLIFF + , + ], + 'fr' => [ + 'messages' => <<<'XLIFF' + + + + + + index.hello + Bonjour + index.hello + + index.hello + + + + + index.greetings + Bienvenue, {firstname} ! + index.greetings + + index.greetings + + + + + + +XLIFF + , + 'validators' => <<<'XLIFF' + + + + + + firstname.error + Le prénom ne peut contenir que des lettres. + firstname.error + + firstname.error + + + + + lastname.error + Le nom de famille ne peut contenir que des lettres. + lastname.error + + lastname.error + + + + + + +XLIFF + , + ], + ], + $expectedTranslatorBag, + ]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/composer.json b/src/Symfony/Component/Translation/Bridge/PoEditor/composer.json new file mode 100644 index 0000000000..56cadf882c --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/po-editor-translation-provider", + "type": "symfony-bridge", + "description": "Symfony PoEditor Translation Provider Bridge", + "keywords": ["poeditor", "translation", "provider"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathieu Santostefano", + "homepage": "https://github.com/welcomattic" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^5.3", + "symfony/translation": "^5.3" + }, + "require-dev": { + "symfony/config": "^4.4|^5.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Translation\\Bridge\\PoEditor\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Translation/Bridge/PoEditor/phpunit.xml.dist b/src/Symfony/Component/Translation/Bridge/PoEditor/phpunit.xml.dist new file mode 100644 index 0000000000..d23f853f5e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/PoEditor/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 1ff428b8e5..3341328a92 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -5,6 +5,8 @@ CHANGELOG --- * Add `translation:pull` and `translation:push` commands to manage translations with third-party providers + * Add `TranslatorBagInterface::getCatalogues` method + * Add support to load XLIFF string in `XliffFileLoader` 5.2.0 ----- diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php index 6f38f37cf3..39d4857b1b 100644 --- a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -25,6 +25,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Loco\LocoProviderFactory::class, 'package' => 'symfony/loco-translation-provider', ], + 'poeditor' => [ + 'class' => Bridge\PoEditor\PoEditorProviderFactory::class, + 'package' => 'symfony/po-editor-translation-provider', + ], ]; public function __construct(Dsn $dsn, string $name = null, array $supported = []) diff --git a/src/Symfony/Component/Translation/Test/ProviderTestCase.php b/src/Symfony/Component/Translation/Test/ProviderTestCase.php index 4eb08604ba..238fd967e3 100644 --- a/src/Symfony/Component/Translation/Test/ProviderTestCase.php +++ b/src/Symfony/Component/Translation/Test/ProviderTestCase.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Translation\Test; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\MockHttpClient; @@ -54,11 +55,17 @@ abstract class ProviderTestCase extends TestCase return $this->client ?? $this->client = new MockHttpClient(); } + /** + * @return LoaderInterface&MockObject + */ protected function getLoader(): LoaderInterface { return $this->loader ?? $this->loader = $this->createMock(LoaderInterface::class); } + /** + * @return LoaderInterface&MockObject + */ protected function getLogger(): LoggerInterface { return $this->logger ?? $this->logger = $this->createMock(LoggerInterface::class); @@ -69,6 +76,9 @@ abstract class ProviderTestCase extends TestCase return $this->defaultLocale ?? $this->defaultLocale = 'en'; } + /** + * @return LoaderInterface&MockObject + */ protected function getXliffFileDumper(): XliffFileDumper { return $this->xliffFileDumper ?? $this->xliffFileDumper = $this->createMock(XliffFileDumper::class);