Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\Exception\ProviderException;
use Symfony\Component\Translation\Exception\RuntimeException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\Provider\ProviderInterface;
Expand All @@ -31,6 +32,9 @@
final class LokaliseProvider implements ProviderInterface
{
private const LOKALISE_GET_KEYS_LIMIT = 5000;
private const PROJECT_TOO_BIG_STATUS_CODE = 413;
private const FAILED_PROCESS_STATUS = ['cancelled', 'failed'];
private const SUCESS_PROCESS_STATUS = 'finished';

private HttpClientInterface $client;
private LoaderInterface $loader;
Expand Down Expand Up @@ -165,7 +169,14 @@ private function exportFiles(array $locales, array $domains): array
}

if (200 !== $response->getStatusCode()) {
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
if (self::PROJECT_TOO_BIG_STATUS_CODE !== ($responseContent['error']['code'] ?? null)) {
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
}
if (!\extension_loaded('zip')) {
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s". Make sure that the "zip" extension is enabled.', $response->getContent(false)), $response);
}

return $this->exportFilesAsync($locales, $domains);
}

// Lokalise returns languages with "-" separator, we need to reformat them to "_" separator.
Expand All @@ -176,6 +187,115 @@ private function exportFiles(array $locales, array $domains): array
return array_combine($reformattedLanguages, $responseContent['files']);
}

/**
* @see https://developers.lokalise.com/reference/download-files-async
*/
private function exportFilesAsync(array $locales, array $domains): array
{
$response = $this->client->request('POST', 'files/async-download', [
'json' => [
'format' => 'symfony_xliff',
'original_filenames' => true,
'filter_langs' => array_values($locales),
'filter_filenames' => array_map($this->getLokaliseFilenameFromDomain(...), $domains),
'export_empty_as' => 'skip',
'replace_breaks' => false,
],
]);

if (200 !== $response->getStatusCode()) {
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
}

$processId = $response->toArray()['process_id'];
while (true) {
$response = $this->client->request('GET', \sprintf('processes/%s', $processId));
$process = $response->toArray()['process'];
if (\in_array($process['status'], self::FAILED_PROCESS_STATUS, true)) {
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
}
if (self::SUCESS_PROCESS_STATUS === $process['status']) {
$downloadUrl = $process['details']['download_url'];
break;
}
usleep(500000);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t we have some kind of timeout in case the API never responds with any of the codes we use to detect success and failure?

}

$response = $this->client->request('GET', $downloadUrl, ['buffer' => false]);
if (200 !== $response->getStatusCode()) {
throw new ProviderException(\sprintf('Unable to download translations file from Lokalise: "%s".', $response->getContent(false)), $response);
}
$zipFile = tempnam(sys_get_temp_dir(), 'lokalise');
$extractPath = $zipFile.'.dir';
try {
if (!$h = @fopen($zipFile, 'w')) {
throw new RuntimeException(error_get_last()['message'] ?? 'Failed to create temporary file.');
}
foreach ($this->client->stream($response) as $chunk) {
fwrite($h, $chunk->getContent());
}
fclose($h);

$zip = new \ZipArchive();
if (!$zip->open($zipFile)) {
throw new RuntimeException('Failed to open zipped translations from Lokalise.');
}

try {
if (!$zip->extractTo($extractPath)) {
throw new RuntimeException('Failed to unzip translations from Lokalize.');
}
} finally {
$zip->close();
}

return $this->getZipContents($extractPath);
} finally {
if (is_resource($h)) {
fclose($h);
}
@unlink($zipFile);
$this->removeDir($extractPath);
}
}

private function getZipContents(string $dir): array
{
$contents = [];
foreach (scandir($dir) as $lang) {
if (\in_array($lang, ['.', '..'], true)) {
continue;
}
$path = $dir.'/'.$lang;
// Lokalise returns languages with "-" separator, we need to reformat them to "_" separator.
$lang = str_replace('-', '_', $lang);
foreach (scandir($path) as $name) {
if (!\in_array($name, ['.', '..'], true)) {
$contents[$lang][$name]['content'] = file_get_contents($path.'/'.$name);
}
}
}

return $contents;
}

private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
rmdir($file->getPathname());
} else {
unlink($file->getPathname());
}
}
rmdir($dir);
}

private function createKeys(array $keys, string $domain): array
{
$keysToCreate = [];
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,100 @@ public function testReadForManyLocalesAndManyDomains(array $locales, array $doma
}
}

/**
* @requires extension zip
*/
public function testReadWithExportAsync()
{
$zipLocation = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'.\DIRECTORY_SEPARATOR.'Symfony-locale.zip';
$firstResponse = function (): ResponseInterface {
return new JsonMockResponse(
['error' => ['code' => 413, 'message' => 'test']],
['http_code' => 406],
);
};
$secondResponse = function (): ResponseInterface {
return new JsonMockResponse(
['process_id' => 123],
);
};
$thirdResponse = function (): ResponseInterface {
return new JsonMockResponse(
['process' => ['status' => 'finished', 'details' => ['download_url' => 'https://api.lokalise.com/Symfony-locale.zip']]],
);
};
$fourResponse = function (string $method, string $url, array $options = []) use ($zipLocation): ResponseInterface {
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/Symfony-locale.zip', $url);
$this->assertFalse($options['buffer']);

return new MockResponse(file_get_contents($zipLocation));
};

$provider = self::createProvider((new MockHttpClient([$firstResponse, $secondResponse, $thirdResponse, $fourResponse]))->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
$translatorBag = $provider->read(['foo'], ['baz']);

// We don't want to assert equality of metadata here, due to the ArrayLoader usage.
/** @var MessageCatalogue $catalogue */
foreach ($translatorBag->getCatalogues() as $catalogue) {
$catalogue->deleteMetadata('', '');
}

$arrayLoader = new ArrayLoader();
$expectedTranslatorBag = new TranslatorBag();
$expectedTranslatorBag->addCatalogue($arrayLoader->load(
[
'how are you' => 'How are you?',
'welcome_header' => 'Hello world!',
],
'en',
'no_filename'
));
$expectedTranslatorBag->addCatalogue($arrayLoader->load(
[
'how are you' => 'Como estas?',
'welcome_header' => 'Hola mundo!',
],
'es',
'no_filename'
));
$this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues());
}

/**
* @requires extension zip
*/
public function testReadWithExportAsyncFailedProcess()
{
$firstResponse = function (): ResponseInterface {
return new JsonMockResponse(
['error' => ['code' => 413, 'message' => 'test']],
['http_code' => 406],
);
};
$secondResponse = function (): ResponseInterface {
return new JsonMockResponse(
['process_id' => 123],
);
};
$thirdResponse = function (): ResponseInterface {
return new JsonMockResponse(
['process' => ['status' => 'failed']],
);
};

$provider = self::createProvider((new MockHttpClient([$firstResponse, $secondResponse, $thirdResponse]))->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');

$this->expectException(ProviderException::class);
$provider->read(['foo'], ['baz']);
}

public function testDeleteProcess()
{
$getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
Expand Down
Loading