feature #40947 [Translation] Added Crowdin Translation Provider (andrii-bodnar)

This PR was squashed before being merged into the 5.3-dev branch.

Discussion
----------

[Translation] Added Crowdin Translation Provider

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | no
| Deprecations? | no
| Tickets       |  <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT
| Doc PR        | <!-- required for new features -->

To follow up on #38475, this PR adds [Crowdin](https://crowdin.com/) Provider.
This provider was removed a few weeks ago from the Translation Providers feature by `@welcoMattic`.

We discussed all the recent changes made on `ProviderInterface`, `TranslatorBagInterface`, and others and I already applied these changes to Crowdin Provider.

Also, this Provider is adapted to work with both [Crowdin](https://crowdin.com/) and [Crowdin Enterprise](https://crowdin.com/enterprise).

The todo list to make it ready is:
- [x] Write integration tests by mocking HTTP Responses

I will make it done before the beginning of May.

Commits
-------

d7fda16262 [Translation] Added Crowdin Translation Provider
This commit is contained in:
Fabien Potencier 2021-05-09 17:52:59 +02:00
commit 6bfb509378
14 changed files with 1267 additions and 0 deletions

View File

@ -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',
];

View File

@ -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'),

View File

@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

View File

@ -0,0 +1,3 @@
vendor/
composer.lock
phpunit.xml

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.3
---
* Create the bridge

View File

@ -0,0 +1,395 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <andrii.bodnar@crowdin.com>
*
* 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;
}
}

View File

@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <andrii.bodnar@crowdin.com>
*
* @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;
}
}
}

View File

@ -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.

View File

@ -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)

View File

@ -0,0 +1,44 @@
<?php
namespace Symfony\Component\Translation\Bridge\Crowdin\Tests;
use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory;
use Symfony\Component\Translation\Provider\ProviderFactoryInterface;
use Symfony\Component\Translation\Test\ProviderFactoryTestCase;
class CrowdinProviderFactoryTest extends ProviderFactoryTestCase
{
public function supportsProvider(): iterable
{
yield [true, 'crowdin://PROJECT_ID:API_TOKEN@default'];
yield [false, 'somethingElse://PROJECT_ID:API_TOKEN@default'];
}
public function createProvider(): iterable
{
yield [
'crowdin://api.crowdin.com/api/v2/',
'crowdin://PROJECT_ID:API_TOKEN@default',
];
yield [
'crowdin://ORGANIZATION_DOMAIN.api.crowdin.com/api/v2/',
'crowdin://PROJECT_ID:API_TOKEN@default?domain=ORGANIZATION_DOMAIN',
];
}
public function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://API_TOKEN@default'];
}
public function incompleteDsnProvider(): iterable
{
yield ['crowdin://default'];
}
public function createFactory(): ProviderFactoryInterface
{
return new CrowdinProviderFactory($this->getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader(), $this->getXliffFileDumper());
}
}

View File

@ -0,0 +1,585 @@
<?php
namespace Symfony\Component\Translation\Bridge\Crowdin\Tests;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProvider;
use Symfony\Component\Translation\Dumper\XliffFileDumper;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\Test\ProviderTestCase;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class CrowdinProviderTest extends ProviderTestCase
{
private const PROJECT_ID = 1;
public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface
{
return new CrowdinProvider($client, $loader, $logger, $this->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'
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="ypeBEso" resname="a">
<source>a</source>
<target>trans_en_a</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF;
$expectedValidatorsFileContent = <<<'XLIFF'
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="is7pld7" resname="post.num_comments">
<source>post.num_comments</source>
<target>{count, plural, one {# comment} other {# comments}}</target>
</trans-unit>
</body>
</file>
</xliff>
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'
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="ypeBEso" resname="a">
<source>a</source>
<target>trans_en_a</target>
</trans-unit>
<trans-unit id="PiPoFgA" resname="b">
<source>b</source>
<target>trans_en_b</target>
</trans-unit>
</body>
</file>
</xliff>
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'
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="ypeBEso" resname="a">
<source>a</source>
<target>trans_en_a</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF;
$expectedMessagesTranslationsContent = <<<'XLIFF'
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="ypeBEso" resname="a">
<source>a</source>
<target>trans_fr_a</target>
</trans-unit>
</body>
</file>
</xliff>
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'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="database" tool-id="crowdin">
<header>
<tool tool-id="crowdin" tool-name="Crowdin" tool-version="1.0.25 20201211-1" tool-company="Crowdin"/>
</header>
<body>
<trans-unit id="crowdin:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Bonjour</target>
</trans-unit>
<trans-unit id="crowdin:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="crowdin:format=icu">
<source>index.greetings</source>
<target state="translated">Bienvenue, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
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'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" tool-id="crowdin">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="crowdin:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Hello</target>
</trans-unit>
<trans-unit id="crowdin:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="crowdin:format=icu">
<source>index.greetings</source>
<target state="translated">Welcome, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
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);
}
}

View File

@ -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"
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony Crowdin Translation Provider Bridge Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

@ -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',