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:
commit
6bfb509378
@ -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',
|
||||
];
|
||||
|
||||
|
@ -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'),
|
||||
|
4
src/Symfony/Component/Translation/Bridge/Crowdin/.gitattributes
vendored
Normal file
4
src/Symfony/Component/Translation/Bridge/Crowdin/.gitattributes
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/Tests export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
3
src/Symfony/Component/Translation/Bridge/Crowdin/.gitignore
vendored
Normal file
3
src/Symfony/Component/Translation/Bridge/Crowdin/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
phpunit.xml
|
@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
5.3
|
||||
---
|
||||
|
||||
* Create the bridge
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
19
src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE
Normal file
19
src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE
Normal 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.
|
29
src/Symfony/Component/Translation/Bridge/Crowdin/README.md
Normal file
29
src/Symfony/Component/Translation/Bridge/Crowdin/README.md
Normal 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)
|
@ -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());
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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>
|
@ -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',
|
||||
|
Reference in New Issue
Block a user