Skip to content

Commit 604182b

Browse files
committed
[Translation][Lokalise] fix "Project too big for sync export"
1 parent 4433ffc commit 604182b

File tree

3 files changed

+200
-0
lines changed

3 files changed

+200
-0
lines changed

src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Translation\Bridge\Lokalise;
1313

1414
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\Translation\Exception\LogicException;
1516
use Symfony\Component\Translation\Exception\ProviderException;
1617
use Symfony\Component\Translation\Loader\LoaderInterface;
1718
use Symfony\Component\Translation\MessageCatalogueInterface;
@@ -31,6 +32,7 @@
3132
final class LokaliseProvider implements ProviderInterface
3233
{
3334
private const LOKALISE_GET_KEYS_LIMIT = 5000;
35+
private const PROJECT_TOO_BIG_STATUS_CODE = 413;
3436

3537
private HttpClientInterface $client;
3638
private LoaderInterface $loader;
@@ -165,6 +167,13 @@ private function exportFiles(array $locales, array $domains): array
165167
}
166168

167169
if (200 !== $response->getStatusCode()) {
170+
if (($errorCode = $responseContent['error']['code'] ?? null) && self::PROJECT_TOO_BIG_STATUS_CODE === $errorCode) {
171+
if (!\extension_loaded('zip')) {
172+
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s". There are too many translations. You need to enable the "zip" extension to use asynchronous export.', $response->getContent(false)), $response);
173+
}
174+
175+
return $this->exportFilesAsync($locales, $domains);
176+
}
168177
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
169178
}
170179

@@ -176,6 +185,109 @@ private function exportFiles(array $locales, array $domains): array
176185
return array_combine($reformattedLanguages, $responseContent['files']);
177186
}
178187

188+
/**
189+
* @see https://developers.lokalise.com/reference/download-files-async
190+
*/
191+
private function exportFilesAsync(array $locales, array $domains): array
192+
{
193+
$response = $this->client->request('POST', 'files/async-download', [
194+
'json' => [
195+
'format' => 'symfony_xliff',
196+
'original_filenames' => true,
197+
'filter_langs' => array_values($locales),
198+
'filter_filenames' => array_map($this->getLokaliseFilenameFromDomain(...), $domains),
199+
'export_empty_as' => 'skip',
200+
'replace_breaks' => false,
201+
],
202+
]);
203+
204+
if (200 !== $response->getStatusCode()) {
205+
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
206+
}
207+
208+
$processId = $response->toArray()['process_id'];
209+
$attempt = 0;
210+
while (true) {
211+
$response = $this->client->request('GET', \sprintf('processes/%s', $processId));
212+
$process = $response->toArray()['process'];
213+
if ('failed' === $process['status']) {
214+
throw new ProviderException(\sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
215+
}
216+
if ('finished' === $process['status']) {
217+
$downloadUrl = $process['details']['download_url'];
218+
break;
219+
}
220+
++$attempt;
221+
usleep(500000 * $attempt);
222+
}
223+
224+
$newfile = \sprintf('%s%s%s.zip', sys_get_temp_dir(), \DIRECTORY_SEPARATOR, uniqid());
225+
$extractPath = \sprintf('%s%s%s', sys_get_temp_dir(), \DIRECTORY_SEPARATOR, uniqid());
226+
if (!copy($downloadUrl, $newfile)) {
227+
throw new LogicException(\sprintf('failed to copy "%s".', $downloadUrl));
228+
}
229+
230+
try {
231+
$zip = new \ZipArchive();
232+
if (!$zip->open($newfile, \ZipArchive::CREATE)) {
233+
throw new LogicException(\sprintf('failed to open zip file "%s".', $newfile));
234+
}
235+
236+
try {
237+
if (!$zip->extractTo($extractPath)) {
238+
throw new LogicException(\sprintf('failed to extract content from zip file "%s".', $newfile));
239+
}
240+
} finally {
241+
$zip->close();
242+
}
243+
244+
$fileContents = $this->getFileContents($extractPath);
245+
246+
// Lokalise returns languages with "-" separator, we need to reformat them to "_" separator.
247+
/** @var array<array-key, array-key> $reformattedLanguages */
248+
$reformattedLanguages = array_map(function ($language) {
249+
return str_replace('-', '_', $language);
250+
}, array_keys($fileContents));
251+
252+
return array_combine($reformattedLanguages, $fileContents);
253+
} finally {
254+
unlink($newfile);
255+
$this->removeDir($extractPath);
256+
}
257+
}
258+
259+
private function getFileContents(string $dir, string $baseDir = ''): array
260+
{
261+
$fileContents = [];
262+
foreach (scandir($dir) as $filename) {
263+
if (\in_array($filename, ['.', '..'])) {
264+
continue;
265+
}
266+
$path = \sprintf('%s%s%s', $dir, \DIRECTORY_SEPARATOR, $filename);
267+
if (is_dir($path)) {
268+
$fileContents = array_merge($fileContents, $this->getFileContents($path, $filename));
269+
continue;
270+
}
271+
$fileContents[$baseDir][$filename]['content'] = file_get_contents($path);
272+
}
273+
274+
return $fileContents;
275+
}
276+
277+
private function removeDir(string $dir): void
278+
{
279+
$it = new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS);
280+
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
281+
foreach ($files as $file) {
282+
if ($file->isDir()) {
283+
rmdir($file->getPathname());
284+
} else {
285+
unlink($file->getPathname());
286+
}
287+
}
288+
rmdir($dir);
289+
}
290+
179291
private function createKeys(array $keys, string $domain): array
180292
{
181293
$keysToCreate = [];
Binary file not shown.

src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,94 @@ public function testReadForManyLocalesAndManyDomains(array $locales, array $doma
697697
}
698698
}
699699

700+
/**
701+
* @requires extension zip
702+
*/
703+
public function testReadWithExportAsync()
704+
{
705+
$firstResponse = function (): ResponseInterface {
706+
return new JsonMockResponse(
707+
['error' => ['code' => 413, 'message' => 'test']],
708+
['http_code' => 406],
709+
);
710+
};
711+
$secondResponse = function (): ResponseInterface {
712+
return new JsonMockResponse(
713+
['process_id' => 123],
714+
);
715+
};
716+
$thirdResponse = function (): ResponseInterface {
717+
$zipLocation = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'.\DIRECTORY_SEPARATOR.'Symfony-locale.zip';
718+
719+
return new JsonMockResponse(
720+
['process' => ['status' => 'finished', 'details' => ['download_url' => $zipLocation]]],
721+
);
722+
};
723+
724+
$provider = self::createProvider((new MockHttpClient([$firstResponse, $secondResponse, $thirdResponse]))->withOptions([
725+
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
726+
'headers' => ['X-Api-Token' => 'API_KEY'],
727+
]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
728+
$translatorBag = $provider->read(['foo'], ['baz']);
729+
730+
// We don't want to assert equality of metadata here, due to the ArrayLoader usage.
731+
/** @var MessageCatalogue $catalogue */
732+
foreach ($translatorBag->getCatalogues() as $catalogue) {
733+
$catalogue->deleteMetadata('', '');
734+
}
735+
736+
$arrayLoader = new ArrayLoader();
737+
$expectedTranslatorBag = new TranslatorBag();
738+
$expectedTranslatorBag->addCatalogue($arrayLoader->load(
739+
[
740+
'how are you' => 'How are you?',
741+
'welcome_header' => 'Hello world!',
742+
],
743+
'en',
744+
'no_filename'
745+
));
746+
$expectedTranslatorBag->addCatalogue($arrayLoader->load(
747+
[
748+
'how are you' => 'Como estas?',
749+
'welcome_header' => 'Hola mundo!',
750+
],
751+
'es',
752+
'no_filename'
753+
));
754+
$this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues());
755+
}
756+
757+
/**
758+
* @requires extension zip
759+
*/
760+
public function testReadWithExportAsyncFailedProcess()
761+
{
762+
$firstResponse = function (): ResponseInterface {
763+
return new JsonMockResponse(
764+
['error' => ['code' => 413, 'message' => 'test']],
765+
['http_code' => 406],
766+
);
767+
};
768+
$secondResponse = function (): ResponseInterface {
769+
return new JsonMockResponse(
770+
['process_id' => 123],
771+
);
772+
};
773+
$thirdResponse = function (): ResponseInterface {
774+
return new JsonMockResponse(
775+
['process' => ['status' => 'failed']],
776+
);
777+
};
778+
779+
$provider = self::createProvider((new MockHttpClient([$firstResponse, $secondResponse, $thirdResponse]))->withOptions([
780+
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
781+
'headers' => ['X-Api-Token' => 'API_KEY'],
782+
]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
783+
784+
$this->expectException(ProviderException::class);
785+
$provider->read(['foo'], ['baz']);
786+
}
787+
700788
public function testDeleteProcess()
701789
{
702790
$getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {

0 commit comments

Comments
 (0)