diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c7d3b7e191..97a9dedb6c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -169,6 +169,7 @@ use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; 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\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; @@ -1341,6 +1342,7 @@ class FrameworkExtension extends Extension } $classToServices = [ + CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', LocoProviderFactory::class => 'translation.provider_factory.loco', ]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 365898ef9c..5c55e91a21 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -11,6 +11,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\Provider\NullProviderFactory; use Symfony\Component\Translation\Provider\TranslationProviderCollection; @@ -33,6 +34,16 @@ return static function (ContainerConfigurator $container) { ->set('translation.provider_factory.null', NullProviderFactory::class) ->tag('translation.provider_factory') + ->set('translation.provider_factory.crowdin', CrowdinProviderFactory::class) + ->args([ + service('http_client'), + service('logger'), + param('kernel.default_locale'), + service('translation.loader.xliff'), + service('translation.dumper.xliff'), + ]) + ->tag('translation.provider_factory') + ->set('translation.provider_factory.loco', LocoProviderFactory::class) ->args([ service('http_client'), diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/.gitattributes b/src/Symfony/Component/Translation/Bridge/Crowdin/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/.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/Crowdin/.gitignore b/src/Symfony/Component/Translation/Bridge/Crowdin/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md new file mode 100644 index 0000000000..bf27ce9c5e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + +* Create the bridge diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php new file mode 100644 index 0000000000..4ec1f5c103 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -0,0 +1,395 @@ + + * + * 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 Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +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; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Andrii Bodnar + * + * In Crowdin: + * * Filenames refer to Symfony's translation domains; + * * Identifiers refer to Symfony's translation keys; + * * Translations refer to Symfony's translated messages + * + * @experimental in 5.3 + */ +final class CrowdinProvider implements ProviderInterface +{ + private $client; + private $loader; + private $logger; + private $xliffFileDumper; + private $defaultLocale; + private $endpoint; + private $projectId; + private $filesDownloader; + + public function __construct(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, XliffFileDumper $xliffFileDumper, string $defaultLocale, string $endpoint, int $projectId, HttpClientInterface $filesDownloader) + { + $this->client = $client; + $this->loader = $loader; + $this->logger = $logger; + $this->xliffFileDumper = $xliffFileDumper; + $this->defaultLocale = $defaultLocale; + $this->endpoint = $endpoint; + $this->projectId = $projectId; + $this->filesDownloader = $filesDownloader; + } + + public function __toString(): string + { + return sprintf('crowdin://%s', $this->endpoint); + } + + /** + * {@inheritdoc} + */ + public function write(TranslatorBagInterface $translatorBag): void + { + $fileList = $this->getFileList(); + + $responses = []; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->getDomains() as $domain) { + if (0 === \count($catalogue->all($domain))) { + continue; + } + + $content = $this->xliffFileDumper->formatCatalogue($catalogue, $domain, ['default_locale' => $this->defaultLocale]); + + $fileId = $this->getFileIdByDomain($fileList, $domain); + + if ($catalogue->getLocale() === $this->defaultLocale) { + if (!$fileId) { + $file = $this->addFile($domain, $content); + } else { + $file = $this->updateFile($fileId, $domain, $content); + } + + if (!$file) { + continue; + } + + $fileList[$file['name']] = $file['id']; + } else { + if (!$fileId) { + continue; + } + + $responses[] = $this->uploadTranslations($fileId, $domain, $content, $catalogue->getLocale()); + } + } + } + + foreach ($responses as $response) { + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to upload translations to Crowdin. Message: "%s".', $response->getContent(false))); + } + } + } + + public function read(array $domains, array $locales): TranslatorBag + { + $fileList = $this->getFileList(); + + $translatorBag = new TranslatorBag(); + $responses = []; + + foreach ($domains as $domain) { + $fileId = $this->getFileIdByDomain($fileList, $domain); + + if (!$fileId) { + continue; + } + + foreach ($locales as $locale) { + if ($locale !== $this->defaultLocale) { + $response = $this->exportProjectTranslations($locale, $fileId); + } else { + $response = $this->downloadSourceFile($fileId); + } + + $responses[] = [ + 'response' => $response, + 'locale' => $locale, + 'domain' => $domain, + ]; + } + } + + foreach ($responses as $responseData) { + /** @var ResponseInterface $response */ + $response = $responseData['response']; + + if (204 === $response->getStatusCode()) { + $this->logger->error(sprintf('No content in exported file. Message: "%s".', $response->getContent(false))); + + continue; + } + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to export file. Message: "%s".', $response->getContent(false))); + + continue; + } + + $exportResponse = $this->filesDownloader->request('GET', $response->toArray()['data']['url']); + + if (200 !== $exportResponse->getStatusCode()) { + $this->logger->error(sprintf('Unable to download file content. Message: "%s".', $response->getContent(false))); + + continue; + } + + $translatorBag->addCatalogue($this->loader->load($exportResponse->getContent(), $responseData['locale'], $responseData['domain'])); + } + + return $translatorBag; + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $fileList = $this->getFileList(); + $responses = []; + + $defaultCatalogue = $translatorBag->getCatalogue($this->defaultLocale); + + if (!$defaultCatalogue) { + $defaultCatalogue = $translatorBag->getCatalogues()[0]; + } + + foreach ($defaultCatalogue->all() as $domain => $messages) { + $fileId = $this->getFileIdByDomain($fileList, $domain); + + if (!$fileId) { + continue; + } + + $stringsMap = $this->mapStrings($fileId); + + foreach ($messages as $id => $message) { + if (!\array_key_exists($id, $stringsMap)) { + continue; + } + + $responses[] = $this->deleteString($stringsMap[$id]); + } + } + + foreach ($responses as $response) { + if (404 === $response->getStatusCode()) { + continue; + } + + if (204 !== $response->getStatusCode()) { + $this->logger->warning(sprintf('Unable to delete string in project %d. Message: "%s".', $this->projectId, $response->getContent(false))); + } + } + } + + private function getFileIdByDomain(array $filesMap, string $domain): ?int + { + return $filesMap[sprintf('%s.%s', $domain, 'xlf')] ?? null; + } + + private function mapStrings(int $fileId): array + { + $result = []; + + $limit = 500; + $offset = 0; + + do { + $strings = $this->listStrings($fileId, $limit, $offset); + + foreach ($strings as $string) { + $result[$string['data']['text']] = $string['data']['id']; + } + + $offset += $limit; + } while (\count($strings) > 0); + + return $result; + } + + private function addFile(string $domain, string $content): ?array + { + $storageId = $this->addStorage($domain, $content); + + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.getMany (Crowdin Enterprise API) + */ + $response = $this->client->request('POST', sprintf('projects/%s/files', $this->projectId), [ + 'json' => [ + 'storageId' => $storageId, + 'name' => sprintf('%s.%s', $domain, 'xlf'), + ], + ]); + + if (201 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to create a File in Crowdin for domain "%s". Message: "%s".', $domain, $response->getContent(false))); + + return null; + } + + return $response->toArray()['data']; + } + + private function updateFile(int $fileId, string $domain, string $content): ?array + { + $storageId = $this->addStorage($domain, $content); + + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.put (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.put (Crowdin Enterprise API) + */ + $response = $this->client->request('PUT', sprintf('projects/%s/files/%d', $this->projectId, $fileId), [ + 'json' => [ + 'storageId' => $storageId, + ], + ]); + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to update file in Crowdin for file ID "%d" and domain "%s". Message: "%s".', $fileId, $domain, $response->getContent(false))); + + return null; + } + + return $response->toArray()['data']; + } + + private function uploadTranslations(int $fileId, string $domain, string $content, string $locale): ResponseInterface + { + $storageId = $this->addStorage($domain, $content); + + /* + * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.postOnLanguage (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.translations.postOnLanguage (Crowdin Enterprise API) + */ + return $this->client->request('POST', sprintf('projects/%s/translations/%s', $this->projectId, $locale), [ + 'json' => [ + 'storageId' => $storageId, + 'fileId' => $fileId, + ], + ]); + } + + private function exportProjectTranslations(string $languageId, int $fileId): ResponseInterface + { + /* + * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.exports.post (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.translations.exports.post (Crowdin Enterprise API) + */ + return $this->client->request('POST', sprintf('projects/%d/translations/exports', $this->projectId), [ + 'json' => [ + 'targetLanguageId' => $languageId, + 'fileIds' => [$fileId], + ], + ]); + } + + private function downloadSourceFile(int $fileId): ResponseInterface + { + /* + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.download.get (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.download.get (Crowdin Enterprise API) + */ + return $this->client->request('GET', sprintf('projects/%d/files/%d/download', $this->projectId, $fileId)); + } + + private function listStrings(int $fileId, int $limit, int $offset): array + { + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.getMany (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.strings.getMany (Crowdin Enterprise API) + */ + $response = $this->client->request('GET', sprintf('projects/%d/strings', $this->projectId), [ + 'query' => [ + 'fileId' => $fileId, + 'limit' => $limit, + 'offset' => $offset, + ], + ]); + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to list strings for file %d in project %d. Message: "%s".', $fileId, $this->projectId, $response->getContent())); + + return []; + } + + return $response->toArray()['data']; + } + + private function deleteString(int $stringId): ResponseInterface + { + /* + * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.delete (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.strings.delete (Crowdin Enterprise API) + */ + return $this->client->request('DELETE', sprintf('projects/%d/strings/%d', $this->projectId, $stringId)); + } + + private function addStorage(string $domain, string $content): int + { + /** + * @see https://support.crowdin.com/api/v2/#operation/api.storages.post (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.storages.post (Crowdin Enterprise API) + */ + $response = $this->client->request('POST', 'storages', [ + 'headers' => [ + 'Crowdin-API-FileName' => urlencode(sprintf('%s.%s', $domain, 'xlf')), + 'Content-Type' => 'application/octet-stream', + ], + 'body' => $content, + ]); + + if (201 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to add a Storage in Crowdin for domain "%s".', $domain), $response); + } + + return $response->toArray()['data']['id']; + } + + private function getFileList(): array + { + $result = []; + + /** + * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) + * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.getMany (Crowdin Enterprise API) + */ + $response = $this->client->request('GET', sprintf('projects/%d/files', $this->projectId)); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException('Unable to list Crowdin files.', $response); + } + + $fileList = $response->toArray()['data']; + + foreach ($fileList as $file) { + $result[$file['data']['name']] = $file['data']['id']; + } + + return $result; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.php new file mode 100644 index 0000000000..c33e36f5e2 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProviderFactory.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\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; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Andrii Bodnar + * + * @experimental in 5.3 + */ +final class CrowdinProviderFactory extends AbstractProviderFactory +{ + private const HOST = 'api.crowdin.com/api/v2/'; + private const DSN_OPTION_DOMAIN = 'domain'; + + /** @var LoaderInterface */ + private $loader; + + /** @var HttpClientInterface */ + private $client; + + /** @var LoggerInterface */ + private $logger; + + /** @var string */ + private $defaultLocale; + + /** @var XliffFileDumper */ + private $xliffFileDumper; + + public function __construct(HttpClientInterface $client, LoggerInterface $logger, string $defaultLocale, LoaderInterface $loader, XliffFileDumper $xliffFileDumper) + { + $this->client = $client; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->loader = $loader; + $this->xliffFileDumper = $xliffFileDumper; + } + + /** + * @return CrowdinProvider + */ + public function create(Dsn $dsn): ProviderInterface + { + if ('crowdin' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'crowdin', $this->getSupportedSchemes()); + } + + $host = 'default' === $dsn->getHost() ? $this->getHost($dsn) : $dsn->getHost(); + $endpoint = sprintf('%s%s', $host, $dsn->getPort() ? ':'.$dsn->getPort() : ''); + + $filesDownloader = $this->client; + + $client = $this->client->withOptions([ + 'base_uri' => 'https://'.$endpoint, + 'headers' => [ + 'Authorization' => 'Bearer '.$this->getPassword($dsn), + ], + ]); + + return new CrowdinProvider($client, $this->loader, $this->logger, $this->xliffFileDumper, $this->defaultLocale, $endpoint, (int) $this->getUser($dsn), $filesDownloader); + } + + protected function getSupportedSchemes(): array + { + return ['crowdin']; + } + + protected function getHost(Dsn $dsn): string + { + $organizationDomain = $dsn->getOption(self::DSN_OPTION_DOMAIN); + + if ($organizationDomain) { + return sprintf('%s.%s', $organizationDomain, self::HOST); + } else { + return self::HOST; + } + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE b/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/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/Crowdin/README.md b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md new file mode 100644 index 0000000000..b55477506d --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/README.md @@ -0,0 +1,29 @@ +Crowdin Translation Provider +============================ + +Provides Crowdin integration for Symfony Translation. + +DSN example +----------- + +``` +// .env file +CROWDIN_DSN=crowdin://PROJECT_ID:API_TOKEN@default?domain=ORGANIZATION_DOMAIN +``` + +where: +- `PROJECT_ID` is your Crowdin Project ID +- `API_KEY` is your Personal Access API Token +- `ORGANIZATION_DOMAIN` is your Crowdin Enterprise Organization domain (required only for Crowdin Enterprise usage) + +[Generate Personal Access Token on Crowdin](https://support.crowdin.com/account-settings/#api) + +[Generate Personal Access Token on Crowdin Enterprise](https://support.crowdin.com/enterprise/personal-access-tokens/) + +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/Crowdin/Tests/CrowdinProviderFactoryTest.php b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderFactoryTest.php new file mode 100644 index 0000000000..4cfa0c40c9 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderFactoryTest.php @@ -0,0 +1,44 @@ +getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader(), $this->getXliffFileDumper()); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php new file mode 100644 index 0000000000..f83820dc63 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php @@ -0,0 +1,585 @@ +getXliffFileDumper(), $defaultLocale, $endpoint, self::PROJECT_ID, new MockHttpClient()); + } + + public function toStringProvider(): iterable + { + yield [ + $this->createProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/'), + 'crowdin://api.crowdin.com/api/v2/', + ]; + + yield [ + $this->createProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://domain.api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'domain.api.crowdin.com/api/v2/'), + 'crowdin://domain.api.crowdin.com/api/v2/', + ]; + + yield [ + $this->createProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/:99', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/:99'), + 'crowdin://api.crowdin.com/api/v2/:99', + ]; + } + + public function testCompleteWriteProcessAddFiles() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + +
+
+ +XLIFF; + + $expectedValidatorsFileContent = <<<'XLIFF' + + + +
+ +
+ + + post.num_comments + {count, plural, one {# comment} other {# comments}} + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('Authorization: Bearer API_TOKEN', $options['normalized_headers']['authorization'][0]); + + return new MockResponse(json_encode(['data' => []])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('{"storageId":19,"name":"messages.xlf"}', $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 199, 'name' => 'messages.xlf']])); + }, + 'addStorage2' => function (string $method, string $url, array $options = []) use ($expectedValidatorsFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: validators.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedValidatorsFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'addFile2' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('{"storageId":19,"name":"validators.xlf"}', $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 200, 'name' => 'validators.xlf']])); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/'); + + $provider->write($translatorBag); + } + + public function testCompleteWriteProcessUpdateFiles() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + + b + trans_en_b + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'UpdateFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PUT', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files/12', $url); + $this->assertSame('{"storageId":19}', $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 199, 'name' => 'messages.xlf']])); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a', 'b' => 'trans_en_b'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/'); + + $provider->write($translatorBag); + } + + public function testCompleteWriteProcessAddFileAndUploadTranslations() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + +
+
+ +XLIFF; + + $expectedMessagesTranslationsContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_fr_a + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'updateFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PUT', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files/12', $url); + $this->assertSame('{"storageId":19}', $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 12, 'name' => 'messages.xlf']])); + }, + 'addStorage2' => function (string $method, string $url, array $options = []) use ($expectedMessagesTranslationsContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesTranslationsContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'UploadTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/fr', $url); + $this->assertSame('{"storageId":19,"fileId":12}', $options['body']); + + return new MockResponse(); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/'); + + $provider->write($translatorBag); + } + + /** + * @dataProvider getResponsesForOneLocaleAndOneDomain + */ + public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'exportProjectTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/exports', $url); + $this->assertSame('{"targetLanguageId":"fr","fileIds":[12]}', $options['body']); + + return new MockResponse(json_encode(['data' => ['url' => 'https://file.url']])); + }, + ]; + + $filesDownloaderResponses = [ + 'downloadFile' => function (string $method, string $url) use ($responseContent): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://file.url/', $url); + + return new MockResponse($responseContent); + }, + ]; + + $loader = $this->getLoader(); + $loader->expects($this->once()) + ->method('load') + ->willReturn($expectedTranslatorBag->getCatalogue($locale)); + + $crowdinProvider = new CrowdinProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getXliffFileDumper(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2', self::PROJECT_ID, new MockHttpClient($filesDownloaderResponses)); + + $translatorBag = $crowdinProvider->read([$domain], [$locale]); + + $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + public function getResponsesForOneLocaleAndOneDomain(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBagFr = new TranslatorBag(); + $expectedTranslatorBagFr->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr', 'messages')); + + yield ['fr', 'messages', <<<'XLIFF' + + + +
+ +
+ + + index.hello + Bonjour + + + index.greetings + Bienvenue, {firstname} ! + + +
+
+XLIFF + , + $expectedTranslatorBagFr, + ]; + } + + /** + * @dataProvider getResponsesForDefaultLocaleAndOneDomain + */ + public function testReadForDefaultLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'downloadSource' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files/12/download', $url); + + return new MockResponse(json_encode(['data' => ['url' => 'https://file.url']])); + }, + ]; + + $filesDownloaderResponses = [ + 'downloadFile' => function (string $method, string $url) use ($responseContent): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://file.url/', $url); + + return new MockResponse($responseContent); + }, + ]; + + $loader = $this->getLoader(); + $loader->expects($this->once()) + ->method('load') + ->willReturn($expectedTranslatorBag->getCatalogue($locale)); + + $crowdinProvider = new CrowdinProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getXliffFileDumper(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2', self::PROJECT_ID, new MockHttpClient($filesDownloaderResponses)); + + $translatorBag = $crowdinProvider->read([$domain], [$locale]); + + $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + public function getResponsesForDefaultLocaleAndOneDomain(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBagEn = new TranslatorBag(); + $expectedTranslatorBagEn->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Hello', + 'index.greetings' => 'Welcome, {firstname} !', + ], 'en', 'messages')); + + yield ['en', 'messages', <<<'XLIFF' + + + +
+ +
+ + + index.hello + Hello + + + index.greetings + Welcome, {firstname} ! + + +
+
+XLIFF + , + $expectedTranslatorBagEn, + ]; + } + + public function testDelete() + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'listStrings1' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings?fileId=12&limit=500&offset=0', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => ['id' => 1, 'text' => 'en a']], + ['data' => ['id' => 2, 'text' => 'en b']], + ], + ])); + }, + 'listStrings2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings?fileId=12&limit=500&offset=500', $url); + + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->any()) + ->method('getContent') + ->with(false) + ->willReturn(json_encode(['data' => []])); + + return $response; + }, + 'deleteString1' => function (string $method, string $url): ResponseInterface { + $this->assertSame('DELETE', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings/1', $url); + + return new MockResponse('', ['http_code' => 204]); + }, + 'deleteString2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('DELETE', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings/2', $url); + + return new MockResponse('', ['http_code' => 204]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => [ + 'en a' => 'en a', + 'en b' => 'en b', + ], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'headers' => [ + 'Authorization' => 'Bearer API_TOKEN', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/'); + + $provider->delete($translatorBag); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/composer.json b/src/Symfony/Component/Translation/Bridge/Crowdin/composer.json new file mode 100644 index 0000000000..03ab95ef04 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/crowdin-translation-provider", + "type": "symfony-bridge", + "description": "Symfony Crowdin Translation Provider Bridge", + "keywords": ["crowdin", "translation", "provider"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Andrii Bodnar", + "homepage": "https://github.com/andrii-bodnar" + }, + { + "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\\Crowdin\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/phpunit.xml.dist b/src/Symfony/Component/Translation/Bridge/Crowdin/phpunit.xml.dist new file mode 100644 index 0000000000..b0a89cd69f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php index 1ee9b55e83..6f38f37cf3 100644 --- a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -17,6 +17,10 @@ use Symfony\Component\Translation\Provider\Dsn; class UnsupportedSchemeException extends LogicException { private const SCHEME_TO_PACKAGE_MAP = [ + 'crowdin' => [ + 'class' => Bridge\Crowdin\CrowdinProviderFactory::class, + 'package' => 'symfony/crowdin-translation-provider', + ], 'loco' => [ 'class' => Bridge\Loco\LocoProviderFactory::class, 'package' => 'symfony/loco-translation-provider',