19declare(strict_types=1);
22use PHPUnit\Framework\Attributes\DataProvider;
23use PHPUnit\Framework\TestCase;
30 private const string TOOL = __DIR__ .
'/../tools/po-migration/convert_module_to_po.php';
34 protected function setUp(): void
37 $this->directory = (string) realpath(sys_get_temp_dir()) .
'/ilias_convert_tool_' . bin2hex(random_bytes(4));
38 mkdir($this->directory .
'/out', 0775,
true);
61 [
'-d',
'error_reporting=-1',
'-d',
'display_errors=stderr', $tool_path, ...
$arguments]
63 $this->assertNotNull($result,
'could not start the child PHP process');
71 $root = $this->directory .
'/repo';
72 $tool_dir = $root .
'/components/ILIAS/Language/tools/po-migration';
73 mkdir($tool_dir, 0775,
true);
74 mkdir($root .
'/lang', 0775,
true);
75 mkdir($root .
'/out', 0775,
true);
76 copy(self::TOOL, $tool_dir .
'/convert_module_to_po.php');
78 mkdir($root .
'/vendor/composer/vendor', 0775,
true);
79 $real_autoload = realpath(__DIR__ .
'/../../../../vendor/composer/vendor/autoload.php');
80 $this->assertIsString($real_autoload,
'the real Composer autoloader was not found');
81 symlink($real_autoload, $root .
'/vendor/composer/vendor/autoload.php');
86 private function toolPathOf(
string $fixture_root): string
88 return $fixture_root .
'/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php';
98 private function writePluralsConfig(
string $fixture_root, array $plural_forms, array $modules): void
101 $fixture_root .
'/components/ILIAS/Language/tools/po-migration/plurals.json',
102 (
string) json_encode([
'plural_forms' => $plural_forms,
'modules' => $modules], JSON_THROW_ON_ERROR)
107 private function writeLangFile(
string $fixture_root,
string $lang_key,
string $content): void
109 if (!str_contains($content, self::START)) {
110 $content = self::START .
"\n" . $content;
112 file_put_contents($fixture_root .
'/lang/ilias_' . $lang_key .
'.lang', $content);
119 foreach (glob(
$directory .
'/*.{po,pot}', GLOB_BRACE) ?: [] as $file) {
120 $files[basename($file)] = (string) file_get_contents($file);
131 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
132 $this->
writeLangFile($root,
'fr',
"tst#:#greeting#:#Bonjour\ntst#:#extra#:#En trop\n");
134 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
136 $this->assertSame(1, $exit_code, $stdout . $stderr);
137 $this->assertStringContainsString(
"reference language 'de' does not have", $stderr);
138 $this->assertStringContainsString(
'fr: extra', $stderr);
139 $this->assertSame([], glob($root .
'/out/*') ?: [],
'nothing must be written once an inconsistency was found');
146 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo#:#Extra\n");
148 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
150 $this->assertSame(0, $exit_code, $stdout . $stderr);
151 $this->assertStringContainsString(
152 'contains "#:#" in its value - only the part before it is used, like the installer does.',
156 $entry = $catalog->find(
null,
'greeting');
157 $this->assertNotNull($entry);
158 $this->assertSame(
'Hallo', $entry->getTranslation());
165 $note =
'Bitte pruefen, ob hier evtl. eine neue Variable (englisch: new variable) gemeint war';
166 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo###$note\n");
168 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
170 $this->assertSame(0, $exit_code, $stdout . $stderr);
172 $entry = $catalog->find(
null,
'greeting');
173 $this->assertNotNull($entry);
174 $this->assertFalse($entry->hasFlag(
'fuzzy'));
175 $this->assertSame([$note], $entry->getExtractedComments());
182 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
183 $this->
writeLangFile($root,
'fr',
"tst#:#greeting#:####13 3 2019 new variable\n");
185 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
187 $this->assertSame(0, $exit_code, $stdout . $stderr);
189 $entry = $catalog->find(
null,
'greeting');
190 $this->assertNotNull($entry);
191 $this->assertTrue($entry->hasFlag(
'fuzzy'));
192 $this->assertSame([], $entry->getExtractedComments());
202 $this->
writeLangFile($root,
'en',
"tst#:#greeting#:#Hello\ntst#:#bye#:#Bye\ntst#:#none#:#\n");
203 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
204 $this->
writeLangFile($root,
'fr',
"tst#:#greeting#:####Bitte uebersetzen\ntst#:#bye#:#Salut\n");
208 $this->assertSame(0, $exit_code, $stdout . $stderr);
211 $this->assertSame([
'greeting',
'bye',
'none'], array_map(
static fn(
$e):
string =>
$e->getId(), $pot->getEntries()),
'msgids from en');
214 $this->assertSame(
'Hallo', $de->find(
null,
'greeting')?->getTranslation());
215 $this->assertFalse($de->find(
null,
'greeting')->hasFlag(
'fuzzy'));
216 $this->assertSame(
'Bye', $de->find(
null,
'bye')?->getTranslation(),
'no line: the en value');
217 $this->assertTrue($de->find(
null,
'bye')->hasFlag(
'fuzzy'));
218 $this->assertSame(
'', $de->find(
null,
'none')?->getTranslation(),
'an empty en value fills nothing');
219 $this->assertFalse($de->find(
null,
'none')->hasFlag(
'fuzzy'));
222 $this->assertSame(
'Hello', $fr->find(
null,
'greeting')?->getTranslation(),
'empty value: the en value');
223 $this->assertTrue($fr->find(
null,
'greeting')->hasFlag(
'fuzzy'));
224 $this->assertSame([
'Bitte uebersetzen'], $fr->find(
null,
'greeting')->getTranslatorComments());
225 $this->assertSame([], $fr->find(
null,
'greeting')->getExtractedComments());
226 $this->assertFalse($fr->find(
null,
'bye')?->hasFlag(
'fuzzy'));
228 $this->assertMatchesRegularExpression(
'/^de\s+1\s+0\s+1\s+yes$/m', $stdout);
229 $this->assertMatchesRegularExpression(
'/^en\s+3\s+0\s+0\s+yes$/m', $stdout);
230 $this->assertMatchesRegularExpression(
'/^fr\s+2\s+0\s+1\s+yes$/m', $stdout);
231 $this->assertStringContainsString(
"(2 entries filled from 'en', flagged fuzzy)", $stdout);
238 $this->
writeLangFile($root,
'en',
"tst#:#item#:#Entries\ntst#:#item_singular#:#One entry\n");
239 $this->
writeLangFile($root,
'de',
"tst#:#item#:#Eintraege\n");
240 $this->
writeLangFile($root,
'fr',
"tst#:#item_singular#:#Une entree\n");
243 [
'en' =>
'nplurals=2; plural=(n != 1);',
'de' =>
'nplurals=2; plural=(n != 1);',
'fr' =>
'nplurals=2; plural=(n > 1);'],
244 [
'tst' => [
'item' => [
'singular' =>
'item_singular']]]
247 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'en', $root .
'/out');
249 $this->assertSame(0, $exit_code, $stdout . $stderr);
251 $this->assertSame([
'Eintraege',
'Eintraege'], $de_item?->getPluralTranslations(),
'the singular key filled from the reference language is not taken over');
252 $this->assertFalse($de_item->hasFlag(
'fuzzy'),
'de: a source language, its own value is present');
254 $this->assertSame([
'Entries',
'Entries'], $fr_item?->getPluralTranslations(),
'the en value in every form');
255 $this->assertTrue($fr_item->hasFlag(
'fuzzy'));
256 $this->assertStringContainsString(
'round-trip identically through PO', $stdout);
263 $this->
writeLangFile($root,
'de',
"tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n");
267 "tst#:#item#:#Des entrees\ntst#:#item_singular#:#Une entree###13 3 2019 new variable\n"
272 "tst#:#item#:#Eintraege ZH\ntst#:#item_singular#:#Ein Eintrag ZH###13 3 2019 new variable\n"
277 'de' =>
'nplurals=2; plural=(n != 1);',
278 'fr' =>
'nplurals=2; plural=(n != 1);',
279 'zh' =>
'nplurals=1; plural=0;',
281 [
'tst' => [
'item' => [
'singular' =>
'item_singular']]]
284 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
286 $this->assertSame(0, $exit_code, $stdout . $stderr);
288 ->find(
null,
'item');
289 $this->assertNotNull($fr_item);
290 $this->assertSame([
'Des entrees',
'Des entrees'], $fr_item->getPluralTranslations(),
'a fuzzy singular key is not taken over');
291 $this->assertTrue($fr_item->hasFlag(
'fuzzy'),
'fr: the own value is copied into both forms');
294 ->find(
null,
'item');
295 $this->assertNotNull($zh_item);
296 $this->assertSame([
'Eintraege ZH'], $zh_item->getPluralTranslations(),
'precondition: single form, own value - not the singular key');
297 $this->assertFalse($zh_item->hasFlag(
'fuzzy'),
'zh: the singular key is never used here, so its fuzziness must not leak in');
304 $this->
writeLangFile($root,
'de',
"tst#:#multi#:#Mehrere Werte\n");
305 $this->
writeLangFile($root,
'fr',
"tst#:#multi#:#Plusieurs valeurs\n");
306 $this->
writeLangFile($root,
'zh',
"tst#:#multi#:#Mehrere Werte ZH\n");
309 [
'de' =>
'nplurals=2; plural=(n != 1);',
'fr' =>
'nplurals=2; plural=(n > 1);',
'zh' =>
'nplurals=1; plural=0;'],
310 [
'tst' => [
'multi' => []]]
313 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
315 $this->assertSame(0, $exit_code, $stdout . $stderr);
317 ->find(
null,
'multi');
318 $this->assertNotNull($de_multi);
319 $this->assertSame([
'Mehrere Werte',
'Mehrere Werte'], $de_multi->getPluralTranslations());
320 $this->assertFalse($de_multi->hasFlag(
'fuzzy'),
'de: a source language');
322 ->find(
null,
'multi');
323 $this->assertSame([
'Plusieurs valeurs',
'Plusieurs valeurs'], $fr_multi?->getPluralTranslations());
324 $this->assertTrue($fr_multi->hasFlag(
'fuzzy'),
'fr: copied into 2 forms');
327 ->find(
null,
'multi');
328 $this->assertNotNull($zh_multi);
329 $this->assertSame([
'Mehrere Werte ZH'], $zh_multi->getPluralTranslations());
330 $this->assertFalse($zh_multi->hasFlag(
'fuzzy'),
'zh: a single form is not "copied" anywhere');
337 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
338 file_put_contents($root .
'/lang/ilias_ABC.lang',
"tst#:#greeting#:#Sollte nicht gelesen werden\n");
340 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
342 $this->assertSame(0, $exit_code, $stdout . $stderr);
343 $this->assertStringContainsString(
'WARNING: skipping', $stderr);
344 $this->assertStringContainsString(
'ilias_ABC.lang', $stderr);
345 $this->assertStringContainsString(
'no language key in its name', $stderr);
346 $this->assertFileDoesNotExist($root .
'/out/tst_ABC.po');
353 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
354 $this->
writeLangFile($root,
'en',
"tst#:#greeting#:#Hello\nmeta#:#meta_l_de#:#German\nmeta#:#meta_l_en#:#English\n");
355 $this->
writeLangFile($root,
'fr',
"tst#:#greeting#:#Bonjour\n");
357 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
359 $this->assertSame(0, $exit_code, $stdout . $stderr);
361 $this->assertSame(
'de', $de->getHeader(
'Language'));
362 $this->assertSame(
'German', $de->getHeader(
'Language-Team'));
364 $this->assertSame(
'English', $en->getHeader(
'Language-Team'));
366 $this->assertNull($fr->getHeader(
'Language-Team'));
367 $this->assertStringContainsString(
"NOTE: no meta_l_fr in lang/ilias_en.lang", $stdout);
369 $this->assertNull($pot->getHeader(
'Language-Team'));
376 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
378 [$exit_code, $stdout, $stderr] = $this->
runToolAt(
380 '--pattern=ilias_%s',
386 $this->assertSame(0, $exit_code, $stdout . $stderr);
387 $this->assertFileExists($root .
'/out/ilias_de.po');
388 $this->assertFileExists($root .
'/out/ilias.pot');
389 $this->assertFileDoesNotExist($root .
'/out/tst_de.po');
390 $this->assertFileDoesNotExist($root .
'/out/tst.pot');
392 $this->assertNotNull($entry);
393 $this->assertSame(
'Hallo', $entry->getTranslation());
400 $this->
writeLangFile($root,
'de',
"tst#:#greeting#:#Hallo\n");
402 [$exit_code, $stdout, $stderr] = $this->
runToolAt(
404 '--pattern=no-placeholder-at-all',
410 $this->assertSame(1, $exit_code, $stdout . $stderr);
411 $this->assertStringContainsString(
'not a valid file name pattern', $stderr);
412 $this->assertSame([], glob($root .
'/out/*') ?: [],
'nothing must be written for an invalid pattern');
415 private const string START =
"<!-- language file start -->";
420 return '$contribute[LanguageFileDirectory::class] = fn() => '
421 .
"new ComponentLanguageFileDirectory(\$this, '$module'$further_arguments);";
426 return "<?php\n\nclass Comp\n{\n public function init(array &\$contribute): void\n {\n $code\n }\n}\n";
430 private function addComponent(
string $root, ?
string $source,
string $vendor =
'ILIAS'): string
432 $directory = $root .
'/components/' . $vendor .
'/Comp';
434 if ($source !==
null) {
435 file_put_contents(
$directory .
'/Comp.php', $source);
451 foreach (glob($root .
'/lang/*') ?: [] as $file) {
452 $snapshot[basename($file)] = [(string) file_get_contents($file), fileperms($file)];
460 private static function idsOf(
string $file): array
463 static fn($entry):
string => $entry->getId(),
464 TranslationCatalog::fromPoFile($file)->getEntries()
472 'key missing from the reference language' => [
473 [
'de' =>
"tst#:#a#:#A\n",
'fr' =>
"tst#:#a#:#B\ntst#:#x#:#X\n"], [],
'fr: x',
475 'empty key in the reference language' => [
476 [
'de' =>
"tst#:#a#:#A\ntst#:##:#E\n"], [],
'de: "" (empty key)',
478 'identical duplicate without the option' => [
479 [
'de' =>
"tst#:#a#:#A\ntst#:#a#:#A\n"], [],
'de: a',
481 'duplicate with different values, also with the option' => [
482 [
'de' =>
"tst#:#a#:#A\ntst#:#a#:#B\n"], [
'--skip-unmigratable-keys'],
'duplicate identifiers',
484 'duplicate differing only in its ### comment, also with the option' => [
485 [
'de' =>
"tst#:#a#:#A###x\ntst#:#a#:#A###y\n"], [
'--skip-unmigratable-keys'],
'duplicate identifiers',
487 'module without any line (migrated already)' => [
488 [
'de' =>
"other#:#a#:#A\n",
'fr' =>
"other#:#a#:#B\n"], [],
'has no lines in any of the 2 lang/ilias_*.lang files',
490 'control character in a key is shown escaped' => [
491 [
'de' =>
"tst#:#a#:#A\n",
'fr' =>
"tst#:#a#:#B\ntst#:#x\033y#:#X\n"], [],
'fr: x\033y',
493 'unknown option' => [
494 [
'de' =>
"tst#:#a#:#A\n"], [
'--remove-from-lang-typo'],
"unknown option '--remove-from-lang-typo'",
505 #[DataProvider('failureProvider')]
510 foreach (
$files as $lang_key => $content) {
515 [$exit_code, $stdout, $stderr] = $this->
runToolAt(
517 '--remove-from-lang',
519 ...[
'tst',
'de', $component_lang]
521 $this->assertSame(1, $exit_code, $stdout . $stderr);
522 $this->assertStringContainsString($expected_stderr, $stderr);
523 $this->assertStringNotContainsString(
"\033", $stderr);
524 $this->assertSame($before, self::langSnapshot($root));
525 $this->assertSame([], glob($component_lang .
'/*') ?: [],
'nothing written');
528 $this->assertSame(1, $exit_code, $stdout . $stderr);
529 $this->assertDirectoryDoesNotExist($root .
'/newout');
536 'short option' => [[
'-x',
'tst',
'de']],
537 'misspelled long option' => [[
'--foo',
'tst',
'de']],
538 'module with a path' => [[
'../x',
'de']],
539 'reference language with a path' => [[
'tst',
'../de']],
540 'a fourth argument' => [[
'tst',
'de',
'out']],
545 #[DataProvider('invalidArgumentsProvider')]
554 $this->assertSame(1, $exit_code, $stdout . $stderr);
555 $this->assertNotSame(
'', $stderr);
556 $this->assertSame($before, self::langSnapshot($root));
557 $this->assertDirectoryDoesNotExist($root .
'/newout');
558 $this->assertDirectoryDoesNotExist($root .
'/x');
564 $this->
writeLangFile($root,
'de',
"tst#:#a#:#A\ntst#:#a#:#A\ntst#:##:#leer\ntst#:#k#1#:#V###note\n");
565 $this->
writeLangFile($root,
'fr',
"tst#:#a#:#FA\ntst#:#c#:#C\ntst#:#c#:#C\ntst#:#x#y#:#X\ntst#:#k#1#:#FV\n");
568 [$exit_code, , $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
569 $this->assertSame(1, $exit_code);
570 $this->assertStringContainsString(
'--skip-unmigratable-keys', $stderr);
571 $this->assertSame([], glob($root .
'/out/*') ?: []);
573 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--skip-unmigratable-keys',
'tst',
'de', $root .
'/out');
575 $this->assertSame(0, $exit_code, $stdout . $stderr);
576 $this->assertStringContainsString(
'WARNING', $stderr);
577 $this->assertStringContainsString(
" de: \"\" (empty key)\n", $stderr);
578 $this->assertStringContainsString(
" fr: c, x#y\n", $stderr,
'the key with # whole');
579 $this->assertStringContainsString(
" fr: c\n", $stderr,
'identical duplicate');
580 $this->assertStringContainsString(
" de: a\n", $stderr);
581 $this->assertStringContainsString(
'round-trip identically', $stdout);
582 $this->assertSame([
'a',
'k#1'], self::idsOf($root .
'/out/tst_de.po'),
'only the migratable keys');
583 $this->assertSame([
'a',
'k#1'], self::idsOf($root .
'/out/tst_fr.po'));
584 $this->assertSame(
'note', TranslationCatalog::fromPoFile($root .
'/out/tst_de.po')->find(
null,
'k#1')?->getExtractedComments()[0] ??
null);
585 $this->assertSame($before, self::langSnapshot($root),
'without --remove-from-lang the .lang files stay');
593 $this->
writeLangFile($root,
'fr',
"tst#:#a#:#B\ntst#:#x#:#1\ntst#:#x#:#2\n");
595 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
596 $this->assertSame(1, $exit_code, $stdout . $stderr);
598 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--skip-unmigratable-keys',
'tst',
'de', $root .
'/out');
599 $this->assertSame(0, $exit_code, $stdout . $stderr);
600 $this->assertStringContainsString(
'WARNING', $stderr);
601 $this->assertStringContainsString(
" fr: x\n", $stderr);
602 $this->assertSame([
'a'], self::idsOf($root .
'/out/tst_fr.po'));
608 foreach ([
'de' => [
'Eintraege',
'Ein Eintrag'],
'es' => [
'Entradas',
'Una entrada'],
'ar' => [
'AR viele',
'AR eins']] as $lang_key => [$plural, $singular]) {
609 $this->
writeLangFile($root, $lang_key,
"tst#:#item#:#$plural\ntst#:#item_singular#:#$singular\n");
614 'de' =>
'nplurals=2; plural=(n != 1);',
615 'es' =>
'nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);',
616 'ar' =>
'nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));',
618 [
'tst' => [
'item' => [
'singular' =>
'item_singular']]]
621 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
623 $this->assertSame(0, $exit_code, $stdout . $stderr);
624 $forms =
static fn(
string $lang_key): ?array => TranslationCatalog::fromPoFile($root .
"/out/tst_$lang_key.po")->find(
null,
'item')?->getPluralTranslations();
625 $this->assertSame([
'Ein Eintrag',
'Eintraege'], $forms(
'de'));
626 $this->assertSame([
'Una entrada',
'Entradas',
'Entradas'], $forms(
'es'));
627 $this->assertSame([
'AR viele',
'AR eins',
'AR viele',
'AR viele',
'AR viele',
'AR viele'], $forms(
'ar'));
633 $this->
writeLangFile($root,
'de',
"tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n");
634 $this->
writeLangFile($root,
'fr',
"tst#:#item#:#Photo\ntst#:#item_singular#:#Photo\n");
637 [
'de' =>
'nplurals=2; plural=(n != 1);',
'fr' =>
'nplurals=2; plural=(n != 1);'],
638 [
'tst' => [
'item' => [
'singular' =>
'item_singular']]]
641 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
643 $this->assertSame(0, $exit_code, $stdout . $stderr);
644 $item = TranslationCatalog::fromPoFile($root .
'/out/tst_fr.po')->find(
null,
'item');
645 $this->assertSame([
'Photo',
'Photo'], $item?->getPluralTranslations());
646 $this->assertFalse($item->hasFlag(
'fuzzy'),
'both forms are translated, they just are equal');
652 $this->
writeLangFile($root,
'de',
"tst#:#a#:#A\ntst#:#item [0]#:#Form\n");
654 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'de', $root .
'/out');
655 $this->assertSame(1, $exit_code, $stdout . $stderr);
656 $this->assertStringContainsString(
'FAILURE', $stderr);
657 $this->assertStringContainsString(
'item [0]', $stderr);
658 $this->assertSame([], glob($root .
'/out/*') ?: [],
'nothing was written');
660 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--skip-unmigratable-keys',
'tst',
'de', $root .
'/out');
661 $this->assertSame(0, $exit_code, $stdout . $stderr);
662 $this->assertStringContainsString(
'item [0]', $stderr);
663 $this->assertSame([
'a'], self::idsOf($root .
'/out/tst_de.po'));
672 $this->
writeLangFile($root,
'de',
"# header\n$start\ncrs#:#a#:#A\ncrsr#:#a#:#Other\ncrs#:#b#:#B###c\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n");
673 $this->
writeLangFile($root,
'fr',
"# header\r\n$start\r\ncrs#:#a#:#FA\r\ncrs#:#b#:#FB\r\ncrsr#:#a#:#R\r\n");
674 $this->
writeLangFile($root,
'en',
"$start\ncrsr#:#a#:#R\ncrs#:#a#:#EA\ncrs#:#b#:#EB");
675 $this->
writeLangFile($root,
'es',
"crs#:#a#:#SA\ncrs#:#b#:#SB\nkeep#:#k#:#K\n");
677 $this->
writeLangFile($root,
'ABC',
"$start\ncrs#:#a#:#ignored\n");
678 chmod($root .
'/lang/ilias_de.lang', 0640);
681 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
683 $this->assertSame(0, $exit_code, $stdout . $stderr);
685 $this->assertSame(
"# header\n$start\ncrsr#:#a#:#Other\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n", $after[
'ilias_de.lang'][0]);
686 $this->assertSame(
"# header\r\n$start\r\ncrsr#:#a#:#R\r\n", $after[
'ilias_fr.lang'][0]);
687 $this->assertSame(
"$start\ncrsr#:#a#:#R", $after[
'ilias_en.lang'][0],
'no final line break stays none');
688 $this->assertSame(
"$start\nkeep#:#k#:#K\n", $after[
'ilias_es.lang'][0]);
689 $this->assertSame($before[
'ilias_it.lang'], $after[
'ilias_it.lang']);
690 $this->assertSame($before[
'ilias_ABC.lang'], $after[
'ilias_ABC.lang']);
691 $this->assertSame(0640, $after[
'ilias_de.lang'][1] & 07777);
692 $this->assertSame(array_keys($before), array_keys($after),
'no temporary file left behind');
693 $this->assertStringContainsString(sprintf(
"removed %5d line(s) of 'crs' from lang/ilias_de.lang", 2), $stdout);
694 $this->assertStringContainsString(sprintf(
"removed %5d line(s) of 'crs' from lang/ilias_en.lang", 2), $stdout);
695 $this->assertStringNotContainsString(
'ilias_it.lang', $stdout);
696 $this->assertFileExists(
$out .
'/crs_de.po');
697 $this->assertFileExists(
$out .
'/crs.pot');
706 $this->
writeLangFile($root,
'de',
"$start\ncrs#:#a#:#A\ncrs#:#a#:#A\ncrs#:##:#leer\ncrs#:#b#:#B\nkeep#:#k#:#K\n");
707 $this->
writeLangFile($root,
'fr',
"$start\ncrs#:#a#:#FA\ncrs#:#extra#:#E\nkeep#:#k#:#K2\n");
709 [$exit_code, $stdout, $stderr] = $this->
runToolAt(
711 '--skip-unmigratable-keys',
712 '--remove-from-lang',
718 $this->assertSame(0, $exit_code, $stdout . $stderr);
720 $this->assertSame(
"$start\nkeep#:#k#:#K\n", $after[
'ilias_de.lang'][0]);
721 $this->assertSame(
"$start\nkeep#:#k#:#K2\n", $after[
'ilias_fr.lang'][0]);
722 $this->assertSame([
'a',
'b'], self::idsOf(
$out .
'/crs_fr.po'));
732 $header =
"crs#:#x#:#Kopfzeile \xFC\n";
733 $this->
writeLangFile($root,
'de',
"$start\ncrs#:#a#:#A\ncrs#:#b#:#B\n");
734 $this->
writeLangFile($root,
'fr',
"$header$start\ncrs#:#b#:#FB\n");
736 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
738 $this->assertSame(0, $exit_code, $stdout . $stderr);
740 $this->assertSame(
"$header$start\n", $after[
'ilias_fr.lang'][0]);
741 $this->assertSame(
"$start\n", $after[
'ilias_de.lang'][0]);
744 $this->assertSame([
'a',
'b'], $ids,
'"x" of the header line is no key');
745 $this->assertSame(
'FB', TranslationCatalog::fromPoFile(
$out .
'/crs_fr.po')->find(
null,
'b')?->getTranslation());
753 $this->
writeLangFile($root,
'de', self::START .
"\ncrs#:#a#:#A\n");
754 file_put_contents($root .
'/lang/ilias_fr.lang',
"crs#:#a#:#FA\ncrs#:#x#:#X\n");
757 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
759 $this->assertSame(1, $exit_code, $stdout . $stderr);
760 $this->assertStringContainsString(
'ilias_fr.lang', $stderr);
761 $this->assertStringContainsString(
'nothing was written', $stderr);
762 $this->assertSame($before, self::langSnapshot($root));
763 $this->assertSame([], glob(
$out .
'/*') ?: []);
765 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'crs',
'de', $root .
'/out');
767 $this->assertSame(0, $exit_code, $stdout . $stderr);
768 $this->assertStringContainsString(
'WARNING: skipping ' . $root .
'/lang/ilias_fr.lang', $stderr);
769 $this->assertFileDoesNotExist($root .
'/out/crs_fr.po',
'"x" would have failed the run if the file was read');
770 $this->assertSame($before, self::langSnapshot($root));
779 $this->
writeLangFile($root,
'de',
"$start\ncrs#:#a#:#Gr\xC3\xBC\xC3\x9Fe\n");
780 $this->
writeLangFile($root,
'fr',
"$start\ncrs#:#a#:#Gr\xFC\xDFe\n");
781 file_put_contents(
$out .
'/crs_de.po',
"existing de\n");
782 file_put_contents(
$out .
'/crs_fr.po',
"existing fr\n");
783 file_put_contents(
$out .
'/crs.pot',
"existing pot\n");
787 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
789 $this->assertSame(1, $exit_code, $stdout . $stderr);
790 $this->assertStringContainsString(
'not valid UTF-8', $stderr);
791 $this->assertStringContainsString(
'fr: a', $stderr);
792 $this->assertSame($lang_before, self::langSnapshot($root));
793 $this->assertSame($out_before, self::files(
$out));
801 file_put_contents($root .
'/out/crs_de.po',
"msgid \"kaputt\n");
803 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'crs',
'de', $root .
'/out');
805 $this->assertSame(0, $exit_code, $stdout . $stderr);
806 $this->assertStringContainsString(
'WARNING: the existing ' . $root .
'/out/crs_de.po is not readable', $stderr);
807 $this->assertSame([
'a'], self::idsOf($root .
'/out/crs_de.po'));
816 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'crs',
'de', $root .
'/typo/lang');
818 $this->assertSame(1, $exit_code, $stdout . $stderr);
819 $this->assertStringContainsString(
"outputDir '$root/typo/lang' does not exist. Nothing was written.", $stderr);
820 $this->assertDirectoryDoesNotExist($root .
'/typo');
830 "tst#:#role#:#ILIAS role name\ntst#:#bad#:#Bad <b onclick=\"x\">en</b>\ntst#:#open#:#Open\ntst#:#none#:#\n"
835 "tst#:#role#:#<nome del Ruolo ILIAS\ntst#:#bad#:#Male\ntst#:#open#:#Apri <a href=\"x\">qui\ntst#:#none#:#<niente\n"
838 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'en', $root .
'/out');
840 $this->assertSame(0, $exit_code, $stdout . $stderr);
841 $this->assertStringContainsString(
"empty once cleaned, replaced by the reference value (fuzzy):\n it: role (", $stderr);
842 $this->assertStringContainsString(
"empty once cleaned, left empty like a missing value:\n it: none (", $stderr);
843 $this->assertMatchesRegularExpression(
'/kept, the build cleans it:\n( .*\n)* en: bad \(/', $stderr);
844 $this->assertMatchesRegularExpression(
'/kept, the build cleans it:\n( .*\n)* it: open \(/', $stderr);
846 $this->assertSame(
'ILIAS role name', $it->find(
null,
'role')?->getTranslation());
847 $this->assertTrue($it->find(
null,
'role')->hasFlag(
'fuzzy'));
848 $this->assertSame(
'Apri <a href="x">qui', $it->find(
null,
'open')?->getTranslation(),
'kept, the build cleans it');
849 $this->assertFalse($it->find(
null,
'open')->hasFlag(
'fuzzy'));
850 $this->assertSame(
'', $it->find(
null,
'none')?->getTranslation());
851 $this->assertFalse($it->find(
null,
'none')->hasFlag(
'fuzzy'));
852 $this->assertSame(
'Male', $it->find(
null,
'bad')?->getTranslation());
853 $this->assertFalse($it->find(
null,
'bad')->hasFlag(
'fuzzy'));
854 $this->assertMatchesRegularExpression(
'/^it\s+4\s+0\s+1\s+yes$/m', $stdout);
856 $this->assertSame(
'Bad <b onclick="x">en</b>', $en->find(
null,
'bad')?->getTranslation(),
'the reference value is kept');
857 $this->assertFalse($en->find(
null,
'bad')->hasFlag(
'fuzzy'));
864 $this->
writeLangFile($root,
'en',
"tst#:#item#:#Items\ntst#:#item_singular#:#One <b>item\n");
865 $this->
writeLangFile($root,
'de',
"tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein <b>Eintrag\n");
868 [
'en' =>
'nplurals=2; plural=(n != 1);',
'de' =>
'nplurals=2; plural=(n != 1);'],
869 [
'tst' => [
'item' => [
'singular' =>
'item_singular']]]
872 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'en', $root .
'/out');
874 $this->assertSame(0, $exit_code, $stdout . $stderr);
875 foreach ([
'en' => [
'One <b>item',
'Items'],
'de' => [
'Ein <b>Eintrag',
'Eintraege']] as $lang_key => $forms) {
877 ->find(
null,
'item');
878 $this->assertSame($forms, $item?->getPluralTranslations(), $lang_key);
886 $this->
writeLangFile($root,
'en',
"tst#:#a#:#A\ntst#:#b#:#B\n");
887 $this->
writeLangFile($root,
'sv',
"tst#:#a#:#Ä####24 10 2013 new variable\ntst#:#b#:#Bb##24 10 2013 new variable\n");
889 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'en', $root .
'/out');
891 $this->assertSame(0, $exit_code, $stdout . $stderr);
893 $this->assertTrue($sv->find(
null,
'a')?->hasFlag(
'fuzzy'));
894 $this->assertSame([], $sv->find(
null,
'a')->getTranslatorComments());
895 $this->assertFalse($sv->find(
null,
'b')?->hasFlag(
'fuzzy'));
896 $this->assertSame(
'Bb##24 10 2013 new variable', $sv->find(
null,
'b')->getTranslation());
903 $this->
writeLangFile($root,
'en',
"tst#:#a#:#A###en note\ntst#:#b#:#B###same note\n");
904 $this->
writeLangFile($root,
'sv',
"tst#:#a#:#Ä###egen anteckning\ntst#:#b#:#Bb###same note\n");
906 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'tst',
'en', $root .
'/out');
908 $this->assertSame(0, $exit_code, $stdout . $stderr);
910 $this->assertSame([
'en note'], $pot->find(
null,
'a')?->getExtractedComments());
912 $this->assertSame([], $en->find(
null,
'a')?->getTranslatorComments());
913 $this->assertSame([
'en note'], $en->find(
null,
'a')->getExtractedComments());
915 $this->assertSame([
'egen anteckning'], $sv->find(
null,
'a')?->getTranslatorComments());
916 $this->assertSame([
'en note'], $sv->find(
null,
'a')->getExtractedComments());
917 $this->assertSame([], $sv->find(
null,
'b')?->getTranslatorComments(),
'a note equal to the reference note is not repeated');
924 'no argument' => [[]],
925 'only options' => [[
'--skip-unmigratable-keys']],
930 #[DataProvider('runsWithoutAModule')]
934 $this->
writeLangFile($root,
'en', self::START .
"\ntos#:#a#:#A\n");
939 $this->assertSame(1, $exit_code, $stdout . $stderr);
940 $this->assertStringContainsString(
'the module is required', $stderr);
941 $this->assertSame($before, self::langSnapshot($root));
942 $this->assertSame([], glob($root .
'/out/*') ?: []);
950 $this->
writeLangFile($root,
'de', self::START .
"\ncrs#:#a#:#A\n");
951 $this->
writeLangFile($root,
'fr', self::START .
"\ncrs#:#a#:#FA\n");
952 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
953 $this->assertSame(0, $exit_code, $stdout . $stderr);
955 $this->assertCount(3, $po_files);
958 [$exit_code, $stdout, $stderr] = $this->
runToolAt($this->
toolPathOf($root),
'--remove-from-lang',
'crs',
'de',
$out);
960 $this->assertSame(1, $exit_code, $stdout . $stderr);
961 $this->assertStringContainsString(
'has no lines in any of the 2 lang/ilias_*.lang files', $stderr);
962 $this->assertStringContainsString(
'most likely it is migrated already', $stderr);
963 $this->assertSame($po_files, self::files(
$out));
964 $this->assertSame(
$lang_files, self::langSnapshot($root));
976 'no outputDir' => [
null, $registered, [],
'the outputDir argument is required'],
977 'outputDir does not exist' => [
'{root}/components/ILIAS/Comp/missing', $registered, [],
'does not exist'],
978 'outputDir outside of components' => [
'{root}/out', $registered, [],
'is not components/<Vendor>/<Component>/lang'],
979 'outputDir is the component, not its lang/' => [
'{root}/components/ILIAS/Comp', $registered, [],
'is not components/<Vendor>/<Component>/lang'],
980 'component class missing' => [
'{root}/components/ILIAS/Comp/lang',
null, [],
'Comp.php was not found'],
981 'contribution only in a comment' => [
982 '{root}/components/ILIAS/Comp/lang',
983 self::componentSource(
"// " . self::contribution(
'tst') .
"\n /* " . self::contribution(
'tst') .
' */'),
985 "contributes no ComponentLanguageFileDirectory for module 'tst'",
987 'contribution for another module' => [
988 '{root}/components/ILIAS/Comp/lang',
991 "contributes no ComponentLanguageFileDirectory for module 'tst'",
993 'contributed path is not lang/' => [
994 '{root}/components/ILIAS/Comp/lang',
997 "from 'translations/', not from lang/",
999 'contributed pattern differs from the contribution default' => [
1000 '{root}/components/ILIAS/Comp/lang',
1003 "pattern 'x_%s', but the run uses 'tst_%s'",
1005 '--pattern differs from the contribution' => [
1006 '{root}/components/ILIAS/Comp/lang',
1008 [
'--pattern=ilias_%s'],
1009 "but the run uses 'ilias_%s'",
1015 #[DataProvider('refusedTargetProvider')]
1018 ?
string $component_source,
1020 string $expected_stderr
1023 $component_lang = $this->
addComponent($root, $component_source);
1024 $this->
writeLangFile($root,
'de', self::START .
"\ntst#:#a#:#A\n");
1025 $this->
writeLangFile($root,
'fr', self::START .
"\ntst#:#a#:#FA\n");
1027 $arguments = [
'tst',
'de', ...($output ===
null ? [] : [str_replace(
'{root}', $root, $output)])];
1031 $this->assertSame(1, $exit_code, $stdout . $stderr);
1032 $this->assertStringContainsString($expected_stderr, $stderr);
1033 $this->assertStringContainsString(
'Nothing was written.', $stderr);
1034 $this->assertSame($before, self::langSnapshot($root));
1035 $this->assertSame([], glob($component_lang .
'/*') ?: []);
1036 $this->assertSame([], glob($root .
'/out/*') ?: []);
1043 'one line, defaults' => [self::componentSource(self::contribution(
'tst')), [],
'tst_de.po'],
1044 'multi-line with fully qualified names and a trailing comma' => [
1045 self::componentSource(
1046 "\$contribute[\\ILIAS\\Language\\ComponentTranslation\\LanguageFileDirectory::class] = fn(): \\X\\LanguageFileDirectory => \n"
1047 .
" new \\ILIAS\\Language\\ComponentTranslation\\ComponentLanguageFileDirectory(\n"
1048 .
" \$this,\n 'tst',\n 'lang/',\n 'x_%s',\n );"
1053 'a comment with another module does not matter' => [
1054 self::componentSource(
'// ' . self::contribution(
'tstx') .
"\n " . self::contribution(
'tst',
", 'lang'")),
1062 #[DataProvider('acceptedTargetProvider')]
1065 $root = $this->buildFixtureRepo();
1066 $component_lang = $this->addComponent($root, $component_source);
1067 $this->writeLangFile($root,
'de', self::START .
"\ntst#:#a#:#A\n");
1069 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'--remove-from-lang', ...
$options, ...[
'tst',
'de', $component_lang]);
1071 $this->assertSame(0, $exit_code, $stdout . $stderr);
1072 $this->assertFileExists($component_lang .
'/' .
$written);
1073 $this->assertSame(self::START .
"\n", self::langSnapshot($root)[
'ilias_de.lang'][0]);
1078 $root = $this->buildFixtureRepo();
1079 $component_lang = $this->addRegisteredComponent($root,
'tst');
1080 symlink($component_lang, $root .
'/link');
1081 $this->writeLangFile($root,
'de', self::START .
"\ntst#:#a#:#A\n");
1083 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'--remove-from-lang',
'tst',
'de', $root .
'/link');
1085 $this->assertSame(0, $exit_code, $stdout . $stderr);
1086 $this->assertFileExists($component_lang .
'/tst_de.po');
1092 $root = $this->buildFixtureRepo();
1093 $component_lang = $this->addRegisteredComponent($root,
'tst');
1094 $this->writeLangFile($root,
'de', self::START .
"\ntst#:#a#:#A\n");
1095 symlink($root .
'/lang/ilias_de.lang', $root .
'/lang/ilias_zz.lang');
1096 $before = self::langSnapshot($root);
1098 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'--remove-from-lang',
'tst',
'de', $component_lang);
1100 $this->assertSame(1, $exit_code, $stdout . $stderr);
1101 $this->assertStringContainsString(
'ilias_zz.lang', $stderr);
1102 $this->assertSame($before, self::langSnapshot($root));
1103 $this->assertTrue(is_link($root .
'/lang/ilias_zz.lang'));
1104 $this->assertSame([], glob($component_lang .
'/*') ?: []);
1106 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'tst',
'de', $root .
'/out');
1108 $this->assertSame(0, $exit_code, $stdout . $stderr);
1109 $this->assertFileExists($root .
'/out/tst_zz.po');
1110 $this->assertSame($before, self::langSnapshot($root));
1115 mkdir($root .
'/components/ILIAS/Meta/lang', 0775,
true);
1116 file_put_contents($root .
'/components/ILIAS/Meta/lang/meta_en.po', $content);
1121 $root = $this->buildFixtureRepo();
1122 $this->writeLangFile($root,
'de',
"tst#:#a#:#A\n");
1123 $this->writeLangFile($root,
'en',
"tst#:#a#:#A\n");
1124 $this->writeMetaPo($root,
"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\nmsgid \"meta_l_de\"\nmsgstr \"German\"\n");
1126 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'tst',
'de', $root .
'/out');
1128 $this->assertSame(0, $exit_code, $stdout . $stderr);
1129 $this->assertSame(
'German', TranslationCatalog::fromPoFile($root .
'/out/tst_de.po')->getHeader(
'Language-Team'));
1130 $this->assertNull(TranslationCatalog::fromPoFile($root .
'/out/tst_en.po')->getHeader(
'Language-Team'));
1132 file_put_contents($root .
'/components/ILIAS/Meta/lang/meta_en.po',
"msgid \"meta_l_de\"\nmsgstr \"unterminated\n\"x\n garbage");
1134 mkdir($root .
'/out');
1136 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root),
'tst',
'de', $root .
'/out');
1138 $this->assertSame(0, $exit_code, $stdout . $stderr);
1139 $this->assertStringContainsString(
'meta_en.po cannot be read for the language names', $stderr);
1140 $this->assertFileExists($root .
'/out/tst_de.po');
static run(array $arguments, ?string $working_directory=null)
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
static fromPoFile(string $file)
static removeDirectory(string $directory)
if(!isset($arguments[0])) if(count( $arguments) > 3) if(! $remove_from_lang &&isset($arguments[2]) &&!is_dir($arguments[2])) $module
if(! $all_ok) if(!is_dir($output_dir) &&! @mkdir($output_dir, 0775, true) &&!is_dir($output_dir)) $written