ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
convert_module_to_po.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
83// a build tool: never reachable through the web server
84if (PHP_SAPI !== 'cli') {
85 exit(1);
86}
87
88require dirname(__DIR__, 5) . '/vendor/composer/vendor/autoload.php';
89
99
104$printable = static function (string $text): string {
105 return addcslashes($text, "\0..\37\177");
106};
107
111$read_file = static function (string $file): string {
112 $content = file_get_contents($file);
113 if ($content === false) {
114 throw new RuntimeException("Cannot read $file");
115 }
116
117 return $content;
118};
119
125$body_lines = static function (string $content): array {
126 $lines = explode("\n", $content);
127 foreach ($lines as $number => $line) {
128 if (trim($line) === '<!-- language file start -->') {
129 return array_slice($lines, $number + 1, null, true);
130 }
131 }
132
133 return [];
134};
135
136$has_start_marker = static function (string $content): bool {
137 foreach (explode("\n", $content) as $line) {
138 if (trim($line) === '<!-- language file start -->') {
139 return true;
140 }
141 }
142
143 return false;
144};
145
150$read_lines = static function (string $file, string $content) use ($body_lines): array {
151 $entries = [];
152 // a "\r" before the "\n" is trimmed below
153 foreach ($body_lines($content) as $number => $line) {
154 // trimmed like ILIAS' own .lang reader, or a value would keep trailing whitespace the database never had
155 $parts = explode('#:#', trim($line));
156 if (count($parts) < 3) {
157 continue;
158 }
159 if (count($parts) > 3) {
160 // parsed like LanguageInstallationManager::insertLanguage(): the part after a further "#:#" is dropped
161 fwrite(STDERR, sprintf(
162 "WARNING: %s:%d contains \"#:#\" in its value - only the part before it is used, like the installer does.\n",
163 $file,
164 $number + 1
165 ));
166 }
167 $entries[] = [$parts[0], $parts[1], $parts[2]];
168 }
169
170 return $entries;
171};
172
176$module_lines = static function (string $file, string $content, string $module) use ($read_lines): array {
177 $lines = [];
178 foreach ($read_lines($file, $content) as [$mod, $key, $raw_value]) {
179 if ($mod === $module) {
180 $lines[] = [$key, $raw_value];
181 }
182 }
183
184 return $lines;
185};
186
191$entries_of = static function (array $lines): array {
192 $entries = [];
193 foreach ($lines as [$key, $raw_value]) {
194 $comment = null;
195 $value = $raw_value;
196 $comment_pos = strpos($raw_value, '###');
197 if ($comment_pos !== false) {
198 $value = substr($raw_value, 0, $comment_pos);
199 $comment = trim(substr($raw_value, $comment_pos + 3));
200 }
201 $entries[$key] = ['value' => $value, 'comment' => $comment];
202 }
203
204 return $entries;
205};
206
210$parse_module = static function (string $file, string $module) use ($read_file, $module_lines, $entries_of): array {
211 return $entries_of($module_lines($file, $read_file($file), $module));
212};
213
222$find_duplicate_keys = static function (array $lines): array {
223 $raw_values = [];
224 foreach ($lines as [$key, $raw_value]) {
225 $raw_values[$key][] = $raw_value;
226 }
227 $duplicates = [];
228 foreach ($raw_values as $key => $values) {
229 if (count($values) > 1) {
230 $duplicates[(string) $key] = count(array_unique($values)) === 1;
231 }
232 }
233
234 return $duplicates;
235};
236
240$format_keys = static function (array $keys) use ($printable): string {
241 return implode(', ', array_map(
242 static fn(int|string $key): string => (string) $key === '' ? '"" (empty key)' : $printable((string) $key),
243 $keys
244 ));
245};
246
254$without_module_lines = static function (string $content, string $module): array {
255 $lines = preg_split('/(?<=\n)/', $content, -1, PREG_SPLIT_NO_EMPTY);
256 $in_body = true;
257 foreach ($lines as $line) {
258 if (trim($line) === '<!-- language file start -->') {
259 $in_body = false;
260 break;
261 }
262 }
263 $kept = [];
264 $removed = 0;
265 foreach ($lines as $line) {
266 if (!$in_body) {
267 $in_body = trim($line) === '<!-- language file start -->';
268 $kept[] = $line;
269 continue;
270 }
271 $parts = explode('#:#', trim($line));
272 if (count($parts) >= 3 && $parts[0] === $module) {
273 $removed++;
274 continue;
275 }
276 $kept[] = $line;
277 }
278 $new_content = implode('', $kept);
279 // the removed line was the last one and had no line break: the new last line loses its own
280 if ($removed > 0 && !str_ends_with($content, "\n") && str_ends_with($new_content, "\n")) {
281 $new_content = substr($new_content, 0, str_ends_with($new_content, "\r\n") ? -2 : -1);
282 }
283
284 return [$new_content, $removed];
285};
286
291$is_fuzzy_marker = LegacyFuzzyMarker::matches(...);
292
298$singular_form_index = static function (PluralForms $plural_forms): ?int {
299 if ($plural_forms->getCount() < 2) {
300 return null;
301 }
302 $index = $plural_forms->formIndexFor(1);
303 foreach ([...range(0, 200), 1000, 1001, 1011, 100000, 1000000, 1000001, 2000000] as $n) {
304 if ($n !== 1 && $plural_forms->formIndexFor($n) === $index) {
305 return null;
306 }
307 }
308
309 return $index;
310};
320$singular_form_of = static function (
321 string $key,
322 array $definition,
323 array $entries,
324 PluralForms $plural_forms,
325 array $filled_keys
327 $singular_key = $definition['singular'] ?? null;
328 if (
329 ($entries[$key]['value'] ?? '') === ''
330 || $singular_key === null
331 || ($entries[$singular_key]['value'] ?? '') === ''
332 || isset($filled_keys[$singular_key])
333 ) {
334 return null;
335 }
336 $singular_comment = $entries[$singular_key]['comment'] ?? null;
337 if ($singular_comment !== null && $is_fuzzy_marker($singular_comment)) {
338 return null;
339 }
340
341 return $singular_form_index($plural_forms);
342};
349$plural_forms_of = static function (
350 string $key,
351 array $definition,
352 array $entries,
353 PluralForms $plural_forms,
354 array $filled_keys
355) use ($singular_form_of): array {
356 $forms = array_fill(0, $plural_forms->getCount(), $entries[$key]['value'] ?? '');
357 $singular_form = $singular_form_of($key, $definition, $entries, $plural_forms, $filled_keys);
358 if ($singular_form !== null) {
359 $forms[$singular_form] = $entries[$definition['singular']]['value'];
360 }
361
362 return $forms;
363};
371$copies_own_value = static function (
372 string $key,
373 array $definition,
374 array $entries,
375 PluralForms $plural_forms,
376 array $filled_keys
377) use ($singular_form_of): bool {
378 // counted by position, not by value: a singular equal to the own value is no copy
379 $own_forms = $plural_forms->getCount()
380 - ($singular_form_of($key, $definition, $entries, $plural_forms, $filled_keys) === null ? 0 : 1);
381
382 return ($entries[$key]['value'] ?? '') !== '' && $own_forms > 1;
383};
384
394$build_catalog = static function (
395 string $module,
396 string $lang_key,
397 array $reference_entries,
398 ?array $translation_entries,
399 array $existing_headers,
400 ?PluralForms $plural_forms,
402 ?string $language_name = null,
403 array $filled_keys = [],
404 bool $is_source_language = false
406 $is_template = $translation_entries === null;
407 $catalog = new TranslationCatalog();
408 foreach ($existing_headers as $name => $value) {
409 $catalog->setHeader($name, $value);
410 }
411 if (!$is_template && $plural_forms !== null) {
412 $catalog->setHeader('Plural-Forms', $plural_forms->getHeader());
413 }
414 $catalog->setHeader('MIME-Version', '1.0');
415 $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
416 $catalog->setHeader('Content-Transfer-Encoding', '8bit');
417 $catalog->setHeader('X-Domain', $module);
418 if (!$is_template) {
419 $catalog->setHeader('Language', $lang_key);
420 }
421 if (!$is_template && $language_name !== null) {
422 $catalog->setHeader('Language-Team', $language_name);
423 }
424
425 foreach ($reference_entries as $key => $ref) {
426 // no msgctxt, see the file docblock
427 $entry = new TranslationEntry(null, (string) $key);
428 $comment = $ref['comment'];
429
430 $plural_definition = $plural_definitions[(string) $key] ?? null;
431 if ($plural_definition !== null) {
432 $entry->setPlural(
433 $plural_definition['plural_id'] ?? $key . '_plural',
434 $is_template ? ['', ''] : $plural_forms_of((string) $key, $plural_definition, $translation_entries, $plural_forms, $filled_keys)
435 );
436 }
437
438 if (!$is_template) {
439 $own = $translation_entries[$key] ?? null;
440 if ($plural_definition === null) {
441 $entry->translate($own['value'] ?? '');
442 }
443 // deliberately no "original" comment (LocalChangeComments): that only exists in the per-installation overlay
444 $own_comment = $own['comment'] ?? null;
445 if (isset($filled_keys[(string) $key])) {
446 $entry->addFlag('fuzzy');
447 }
448 if ($own_comment !== null && $is_fuzzy_marker($own_comment)) {
449 $entry->addFlag('fuzzy');
450 } elseif ($own_comment !== null && $own_comment !== '' && $own_comment !== $comment) {
451 $entry->addTranslatorNote($own_comment);
452 }
453 // the own value copied into several forms still needs a translation, except in a source language
454 if (!$is_source_language && $plural_definition !== null && $copies_own_value((string) $key, $plural_definition, $translation_entries, $plural_forms, $filled_keys)) {
455 $entry->addFlag('fuzzy');
456 }
457 }
458
459 if ($comment !== null && $comment !== '' && !$is_fuzzy_marker($comment)) {
460 $entry->addExtractedComment($comment);
461 }
462
463 $catalog->add($entry);
464 }
465
466 return $catalog;
467};
468
479$removal_target_problem = static function (string $repo_root, ?string $output_argument, string $module, string $pattern) use ($printable): ?string {
480 if ($output_argument === null) {
481 return 'the outputDir argument is required - the existing lang/ directory of the component that contributes the module.';
482 }
483 $resolved = realpath($output_argument);
484 if ($resolved === false || !is_dir($resolved)) {
485 return "outputDir '" . $printable($output_argument) . "' does not exist - the component's lang/ directory must exist before the run.";
486 }
487 $components = realpath($repo_root . '/components');
488 if (
489 $components === false
490 || preg_match('#\A' . preg_quote($components, '#') . '/([^/]+)/([^/]+)/lang\z#', $resolved, $m) !== 1
491 ) {
492 return "outputDir '" . $printable($output_argument) . "' is not components/<Vendor>/<Component>/lang of this repository.";
493 }
494 $component_file = "$components/{$m[1]}/{$m[2]}/{$m[2]}.php";
495 $source = is_file($component_file) ? file_get_contents($component_file) : false;
496 if ($source === false) {
497 return "the component class $component_file was not found.";
498 }
499 $code = '';
500 foreach (token_get_all($source) as $token) {
501 if (!is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
502 $code .= is_array($token) ? $token[1] : $token;
503 }
504 }
505 $class = '(?:\\\\?(?:\w+\\\\)*)?';
506 $string = static fn(int $quote, int $text): string => "(['\"])(?<t$text>[^'\"]*)\\$quote";
507 preg_match_all(
508 '/\$contribute\s*\[\s*' . $class . 'LanguageFileDirectory::class\s*\]\s*=[^;]*?new\s+' . $class
509 . 'ComponentLanguageFileDirectory\s*\‍(\s*\$this\s*,\s*' . $string(1, 1)
510 . '\s*(?:,\s*' . $string(3, 2) . '\s*)?(?:,\s*' . $string(5, 3) . '\s*)?,?\s*\‍)/',
511 $code,
512 $contributions,
513 PREG_SET_ORDER
514 );
515 foreach ($contributions as $contribution) {
516 if ($contribution['t1'] !== $module) {
517 continue;
518 }
519 $path_inside = ($contribution['t2'] ?? '') === '' ? 'lang/' : $contribution['t2'];
520 $contributed_pattern = ($contribution['t3'] ?? '') === '' ? $module . '_%s' : $contribution['t3'];
521 if (trim($path_inside, '/') !== 'lang') {
522 return "$component_file contributes module '$module' from '" . $printable($path_inside) . "', not from lang/.";
523 }
524 if ($contributed_pattern !== $pattern) {
525 return "$component_file contributes module '$module' with the file name pattern '" . $printable($contributed_pattern)
526 . "', but the run uses '" . $printable($pattern) . "' (see --pattern).";
527 }
528
529 return null;
530 }
531
532 return "$component_file contributes no ComponentLanguageFileDirectory for module '$module' (expected: "
533 . "\$contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory(\$this, '$module'); "
534 . "register the contribution first).";
535};
536
537$existing_headers = static function (string $file): array {
538 try {
539 return is_file($file) ? TranslationCatalog::fromPoFile($file)->getHeaders() : [];
540 } catch (RuntimeException $e) {
541 fwrite(STDERR, "WARNING: the existing $file is not readable, its headers are not kept (the file is replaced): "
542 . $e->getMessage() . "\n");
543 return [];
544 }
545};
546
547$write = static function (string $file, string $content): void {
548 if (file_put_contents($file, $content) === false) {
549 throw new RuntimeException("Cannot write $file");
550 }
551};
552
553// outputDir defaults to this tool's output/<module> scratch folder; for a module whose component already contributes
554// a ComponentLanguageFileDirectory pass that component's lang/ directory, e.g. for "tos":
555// php convert_module_to_po.php tos en ../../../TermsOfService/lang
556
558$pattern = null;
561foreach (array_slice($argv, 1) as $argument) {
562 if (str_starts_with($argument, '--pattern=')) {
563 $pattern = substr($argument, strlen('--pattern='));
564 continue;
565 }
566 if ($argument === '--skip-unmigratable-keys') {
568 continue;
569 }
570 if ($argument === '--remove-from-lang') {
571 $remove_from_lang = true;
572 continue;
573 }
574 // a mistyped option must not end up as module name or output directory
575 if (str_starts_with($argument, '-')) {
576 fwrite(STDERR, "FAILURE: unknown option '" . $printable($argument) . "' (known: --pattern=<pattern>, --skip-unmigratable-keys, --remove-from-lang).\n");
577 exit(1);
578 }
579 $arguments[] = $argument;
580}
581$usage = "Usage: php convert_module_to_po.php [--pattern=<pattern>] [--skip-unmigratable-keys] [--remove-from-lang]"
582 . " <module> [referenceLangKey] [outputDir]\n";
583if (!isset($arguments[0])) {
584 fwrite(STDERR, "FAILURE: the module is required. $usage");
585 exit(1);
586}
587if (count($arguments) > 3) {
588 $surplus = $printable(implode("' '", array_slice($arguments, 3)));
589 fwrite(STDERR, "FAILURE: too many arguments ('$surplus'). $usage");
590 exit(1);
591}
592// a mistyped outputDir must not become a new directory tree (--remove-from-lang checks it further below)
593if (!$remove_from_lang && isset($arguments[2]) && !is_dir($arguments[2])) {
594 fwrite(STDERR, "FAILURE: outputDir '" . $printable($arguments[2]) . "' does not exist. Nothing was written.\n");
595 exit(1);
596}
599// a module name is a lng_data module: it names the output directory and the files
600if (preg_match('/\A[A-Za-z0-9_]+\z/', $module) !== 1 || preg_match('/\A[A-Za-z0-9_]+\z/', $reference_lang_key) !== 1) {
601 fwrite(STDERR, "FAILURE: module and reference language may only consist of A-Z, a-z, 0-9 and \"_\".\n");
602 exit(1);
603}
604$pattern ??= $module . '_%s';
605try {
606 MigratedLanguageFilePaths::assertValidShippedFileNamePattern($pattern);
607} catch (InvalidArgumentException $e) {
608 fwrite(STDERR, $e->getMessage() . "\n");
609 exit(1);
610}
611
612$repo_root = dirname(__DIR__, 5);
613
614// --remove-from-lang removes the module's only other source: first check that the .po files land where the installation reads them
616 $target_problem = $removal_target_problem($repo_root, $arguments[2] ?? null, $module, $pattern);
617 if ($target_problem !== null) {
618 fwrite(STDERR, "FAILURE: --remove-from-lang: $target_problem Nothing was written.\n");
619 exit(1);
620 }
621}
622
623$plural_config_file = __DIR__ . '/plurals.json';
624$plural_config = ['plural_forms' => [], 'modules' => []];
625if (is_file($plural_config_file)) {
626 try {
627 $plural_config = json_decode((string) file_get_contents($plural_config_file), true, 16, JSON_THROW_ON_ERROR);
628 } catch (JsonException $e) {
629 fwrite(STDERR, "FAILURE: $plural_config_file is no valid JSON: {$e->getMessage()}\n");
630 exit(1);
631 }
632} else {
633 echo "NOTE: no plurals.json next to the tool - no Plural-Forms headers and no plural messages are written.\n";
634}
636foreach ($plural_config['plural_forms'] ?? [] as $lang_key => $header) {
637 try {
638 $plural_rules[(string) $lang_key] = PluralForms::fromHeader((string) $header);
639 } catch (InvalidArgumentException $e) {
640 fwrite(STDERR, "FAILURE: invalid Plural-Forms of '$lang_key' in $plural_config_file: {$e->getMessage()}\n");
641 exit(1);
642 }
643}
646 static fn(mixed $definition): array => is_array($definition) ? $definition : [],
647 $plural_config['modules'][$module] ?? []
648);
649$output_dir = isset($arguments[2]) ? rtrim($arguments[2], '/') : (__DIR__ . '/output/' . $module);
651
652$lang_files = glob($lang_dir . '/ilias_*.lang');
654if ($lang_files === []) {
655 fwrite(STDERR, "No lang files found under $lang_dir\n");
656 exit(1);
657}
658
660// lang_key => path, hash, number of the module's lines and the content without them of every file read
664// lang_key => identifier => whether all occurrences are the same, see $find_duplicate_keys
667foreach ($lang_files as $file) {
668 if (preg_match('/ilias_([a-z]+)\.lang\z/', $file, $m) !== 1) {
669 fwrite(STDERR, "WARNING: skipping $file - no language key in its name.\n");
670 continue;
671 }
672 // a symbolic link would only fail when written, after other files are changed already
673 if (is_link($file) || !is_file($file)) {
674 if ($remove_from_lang) {
675 $unsafe_files[] = $file;
676 continue;
677 }
678 if (!is_file($file)) {
679 fwrite(STDERR, "WARNING: skipping $file - no regular file.\n");
680 continue;
681 }
682 }
683 $lang_key = $m[1];
684 $content = $read_file($file);
685 if (!$has_start_marker($content)) {
686 $files_without_marker[] = $file;
687 continue;
688 }
689 $lines = $module_lines($file, $content, $module);
690 [$new_content, $removed] = $without_module_lines($content, $module);
691 // a safeguard: exactly the lines read are removed
692 if ($remove_from_lang && $removed !== count($lines)) {
693 $removal_problems[] = "$file: " . count($lines) . " line(s) of the module were read, but $removed would be removed";
694 }
695 $per_language[$lang_key] = $entries_of($lines);
696 $read_files[$lang_key] = [
697 'path' => $file,
698 'hash' => hash('sha256', $content),
699 'lines' => count($lines),
700 'new_content' => $new_content,
701 'removed' => $removed,
702 ];
703 $duplicates[$lang_key] = $find_duplicate_keys($lines);
704}
705if ($unsafe_files !== []) {
706 fwrite(STDERR, "FAILURE: --remove-from-lang only changes regular files, these are symbolic links or no files (nothing was written):\n "
707 . implode("\n ", $unsafe_files) . "\n");
708 exit(1);
709}
711 fwrite(STDERR, "FAILURE: --remove-from-lang: these files have no line \"<!-- language file start -->\", the installer"
712 . " does not read them (nothing was written):\n " . implode("\n ", $files_without_marker) . "\n");
713 exit(1);
714}
715foreach ($files_without_marker as $file) {
716 fwrite(STDERR, "WARNING: skipping $file - no line \"<!-- language file start -->\", the installer does not read it.\n");
717}
718if ($removal_problems !== []) {
719 fwrite(STDERR, "FAILURE: --remove-from-lang (nothing was written):\n " . implode("\n ", $removal_problems) . "\n");
720 exit(1);
721}
722
723// the .po files are UTF-8 and are read back as such: a value in another encoding (e.g. Latin-1) is no text there
725foreach ($per_language as $lang_key => $entries) {
726 foreach ($entries as $key => $entry) {
727 if (!mb_check_encoding((string) $key . $entry['value'] . ($entry['comment'] ?? ''), 'UTF-8')) {
728 $invalid_encoding[$lang_key][] = mb_check_encoding((string) $key, 'UTF-8') ? (string) $key : bin2hex((string) $key) . ' (hex)';
729 }
730 }
731}
732if ($invalid_encoding !== []) {
733 fwrite(STDERR, "FAILURE: module '$module' has lines that are not valid UTF-8 in the source .lang files (nothing was written):\n");
734 foreach ($invalid_encoding as $lang_key => $keys) {
735 fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n");
736 }
737 exit(1);
738}
739
740// a module removed with --remove-from-lang before (or a misspelled module name): nothing to convert,
741// and the existing .po/.pot - the module's only source now - must stay as they are
742if (array_sum(array_column($read_files, 'lines')) === 0) {
743 fwrite(STDERR, "FAILURE: module '$module' has no lines in any of the " . count($read_files) . " lang/ilias_*.lang files under $lang_dir"
744 . " - most likely it is migrated already (its lines removed with --remove-from-lang), or the module name is misspelled."
745 . " Nothing was written; existing .po/.pot files are unchanged.\n");
746 exit(1);
747}
748
750 fwrite(STDERR, "Reference language '$reference_lang_key' not found.\n");
751 exit(1);
752}
753$reference_keys = array_map('strval', array_keys($per_language[$reference_lang_key]));
754
755// fail before writing anything rather than silently keeping one of the occurrences, except on request for keys whose
756// occurrences are all the same or that are left out anyway
759foreach ($duplicates as $lang_key => $keys) {
760 foreach ($keys as $key => $identical) {
761 $left_out = (string) $key === '' || str_contains((string) $key, ' [') || !in_array((string) $key, $reference_keys, true);
762 if ($skip_unmigratable_keys && ($identical || $left_out)) {
763 $identical_duplicate_report[$lang_key][] = $key;
764 } else {
765 $duplicate_report[$lang_key][] = $key;
766 }
767 }
768}
769if ($duplicate_report !== []) {
770 fwrite(STDERR, "FAILURE: module '$module' has duplicate identifiers in the source .lang files (nothing was written):\n");
771 foreach ($duplicate_report as $lang_key => $duplicates) {
772 fwrite(STDERR, " $lang_key: " . $format_keys($duplicates) . "\n");
773 }
775 fwrite(STDERR, " (--skip-unmigratable-keys keeps one occurrence of a key whose occurrences all have the same value)\n");
776 }
777 exit(1);
778}
779if ($identical_duplicate_report !== []) {
780 fwrite(STDERR, "WARNING: module '$module' has duplicate identifiers in the source .lang files - all occurrences the same, one is used; or left out anyway, see below (--skip-unmigratable-keys):\n");
781 foreach ($identical_duplicate_report as $lang_key => $duplicates) {
782 fwrite(STDERR, " $lang_key: " . $format_keys($duplicates) . "\n");
783 }
784}
785
786// the catalogs are built from the reference keys: a key only another language has would be dropped silently. The
787// empty key (msgid "" is the PO header) and a key containing " [" (the form syntax of PluralFormKey) cannot be migrated.
789foreach ($per_language as $lang_key => $entries) {
790 $keys = array_map('strval', array_keys($entries));
791 $unmigratable = array_values(array_diff($keys, $reference_keys));
792 if (isset($entries[''])) {
793 $unmigratable = array_values(array_unique(['', ...$unmigratable]));
794 }
795 $form_like = array_filter($keys, static fn(string $key): bool => str_contains($key, ' ['));
796 $unmigratable = array_values(array_unique([...$unmigratable, ...$form_like]));
797 if ($unmigratable !== []) {
798 $unmigratable_keys[$lang_key] = $unmigratable;
799 }
800}
802 fwrite(STDERR, "FAILURE: module '$module' has keys that the reference language '$reference_lang_key' does not have, the empty key, or a key containing \" [\" (the syntax of a plural form) (nothing was written):\n");
803 foreach ($unmigratable_keys as $lang_key => $keys) {
804 fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n");
805 }
806 fwrite(STDERR, " (--skip-unmigratable-keys leaves these keys out instead)\n");
807 exit(1);
808}
809if ($unmigratable_keys !== []) {
810 fwrite(STDERR, "WARNING: module '$module' has keys that the reference language '$reference_lang_key' does not have, the empty key, or a key containing \" [\" (the syntax of a plural form) - left out (--skip-unmigratable-keys):\n");
811 foreach ($unmigratable_keys as $lang_key => $keys) {
812 fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n");
813 foreach ($keys as $key) {
814 unset($per_language[$lang_key][$key]);
815 }
816 }
817}
818
820// languages whose values are original texts, not translations: the reference language and the mandatory languages of
821// AddLanguageEntry - a plural value copied into several forms is not flagged fuzzy there
822$source_lang_keys = array_values(array_unique([$reference_lang_key, 'de', 'en']));
823if ($reference_entries === []) {
824 $languages_with_lines = array_keys(array_filter($read_files, static fn(array $read): bool => $read['lines'] > 0));
825 fwrite(STDERR, "FAILURE: module '$module' has no (migratable) entries in the reference language '$reference_lang_key',"
826 . " but lines in " . implode(', ', $languages_with_lines) . ". Nothing was written.\n");
827 exit(1);
828}
829
830// a value cleaned to nothing is missing (an empty value is not served) and gets the reference value below
834foreach ($per_language as $lang_key => $entries) {
835 foreach ($entries as $key => $entry) {
836 $violations = $markup_policy->findViolations($entry['value']);
837 if ($violations === []) {
838 continue;
839 }
840 $markup_report[$lang_key][(string) $key] = true;
841 $emptied = $lang_key !== $reference_lang_key && $markup_policy->sanitize($entry['value']) === '';
842 if ($emptied) {
843 $per_language[$lang_key][$key]['value'] = '';
844 }
845 $message = match (true) {
846 !$emptied => 'kept, the build cleans it',
847 ($per_language[$reference_lang_key][$key]['value'] ?? '') === ''
848 => 'empty once cleaned, left empty like a missing value',
849 default => 'empty once cleaned, replaced by the reference value (fuzzy)',
850 };
851 $markup_messages[$message][] = " $lang_key: " . $printable($key . ' (' . implode('; ', $violations) . ')');
852 }
853}
854foreach ($markup_messages as $message => $lines) {
855 fwrite(STDERR, "WARNING: module '$module': values with markup the build removes - $message:\n"
856 . implode("\n", $lines) . "\n");
857}
858
859$own_entry_counts = array_map('count', $per_language);
861foreach ($per_language as $lang_key => $entries) {
862 if ($lang_key === $reference_lang_key) {
863 continue;
864 }
865 foreach ($reference_entries as $key => $ref) {
866 $key = (string) $key;
867 if ($ref['value'] !== '' && ($entries[$key]['value'] ?? '') === '') {
868 $per_language[$lang_key][$key] = ['value' => $ref['value'], 'comment' => $entries[$key]['comment'] ?? null];
869 $filled[$lang_key][$key] = true;
870 }
871 }
872}
873
875foreach ($plural_definitions as $key => $definition) {
876 if (!isset($reference_entries[$key])) {
877 $plural_problems[] = "plural message '$key' is no key of the module";
878 }
879 if (isset($definition['singular']) && !isset($reference_entries[$definition['singular']])) {
880 $plural_problems[] = "singular key '{$definition['singular']}' of '$key' is no key of the module";
881 }
882}
883if ($plural_definitions !== []) {
884 foreach (array_keys($per_language) as $lang_key) {
885 if (!isset($plural_rules[$lang_key])) {
886 $plural_problems[] = "no Plural-Forms for language '$lang_key'";
887 }
888 }
889}
890if ($plural_problems !== []) {
891 fwrite(STDERR, "FAILURE: plurals.json does not fit module '$module' (nothing was written):\n " . implode("\n ", array_map($printable, $plural_problems)) . "\n");
892 exit(1);
893}
894
896$english_lang_file = $repo_root . '/lang/ilias_en.lang';
897if (is_file($english_lang_file)) {
898 foreach ($parse_module($english_lang_file, 'meta') as $key => $entry) {
899 $name = trim($entry['value']);
900 if (str_starts_with((string) $key, 'meta_l_') && $name !== '') {
901 $language_names[substr((string) $key, strlen('meta_l_'))] = $name;
902 }
903 }
904}
905// once "meta" is migrated, its shipped English .po is the source (default file name in a component's lang/ directory)
906if ($language_names === []) {
907 foreach (glob($repo_root . '/components/*/*/lang/meta_en.po') ?: [] as $meta_po) {
908 try {
909 foreach (TranslationCatalog::fromPoFile($meta_po)->getEntries() as $entry) {
910 $name = trim($entry->getTranslation());
911 if (in_array($entry->getContext(), [null, 'meta'], true) && str_starts_with($entry->getId(), 'meta_l_') && $name !== '') {
912 $language_names[substr($entry->getId(), strlen('meta_l_'))] = $name;
913 }
914 }
915 } catch (RuntimeException $e) {
916 fwrite(STDERR, "WARNING: $meta_po cannot be read for the language names: {$e->getMessage()}\n");
917 }
918 }
919}
920foreach (array_keys($per_language) as $lang_key) {
921 if (!isset($language_names[$lang_key])) {
922 echo "NOTE: no meta_l_$lang_key in lang/ilias_en.lang (or a shipped meta_en.po) - the Language-Team header of '$lang_key' is left as it is.\n";
923 }
924}
925
926// every file is built and read back in memory first: a failing run writes nothing
927$pot_path = $output_dir . '/' . MigratedLanguageFilePaths::templateFileName($pattern, $module);
931try {
933} catch (RuntimeException $e) {
934 fwrite(STDERR, "FAILURE: the template of module '$module' cannot be written: {$e->getMessage()} Nothing was written.\n");
935 exit(1);
936}
937foreach ($per_language as $lang_key => $entries) {
938 $po_path = $output_dir . '/' . sprintf($pattern, $lang_key) . '.po';
939 $po_mismatches = [];
940 $po_headers = $existing_headers($po_path);
941 try {
942 $contents[$po_path] = $build_catalog(
943 $module,
944 $lang_key,
946 $entries,
947 $po_headers,
948 $plural_rules[$lang_key] ?? null,
950 $language_names[$lang_key] ?? null,
951 $filled[$lang_key] ?? [],
952 in_array($lang_key, $source_lang_keys, true)
953 )->toPoString();
954 $parsed = TranslationCatalog::fromPoString($contents[$po_path]);
955 } catch (RuntimeException $e) {
956 $parsed = null;
957 $po_mismatches[] = '(not readable back) ' . $e->getMessage();
958 }
959
960 // self-check: every value (also one taken from the reference language, flagged fuzzy) must match and nothing else may be in it
961 $expected_keys = array_unique(array_merge(
962 array_map('strval', array_keys($reference_entries)),
963 array_map('strval', array_keys($entries))
964 ));
965 foreach ($parsed !== null ? $expected_keys : [] as $key) {
966 $expected = $entries[$key]['value'] ?? '';
967 $parsed_entry = $parsed->find(null, $key);
968 // find(null, ...) also finds msgctxt "" (same library id) - only an entry without msgctxt counts
969 if ($parsed_entry?->getContext() !== null) {
970 $po_mismatches[] = $key;
971 continue;
972 }
973 if (isset($filled[$lang_key][$key]) && $parsed_entry?->hasFlag('fuzzy') !== true) {
974 $po_mismatches[] = $key . ' (not fuzzy)';
975 continue;
976 }
977 $plural_definition = $plural_definitions[$key] ?? null;
978 if ($plural_definition === null) {
979 if ($parsed_entry?->isPlural() !== false || $parsed_entry->getTranslation() !== $expected) {
980 $po_mismatches[] = $key;
981 }
982 continue;
983 }
984 $rule = $plural_rules[$lang_key];
985 $expected_forms = $plural_forms_of($key, $plural_definition, $entries, $rule, $filled[$lang_key] ?? []);
986 $parsed_forms = array_pad(array_slice($parsed_entry?->getPluralTranslations() ?? [], 0, $rule->getCount()), $rule->getCount(), '');
987 if (
988 $parsed_entry?->getPluralId() !== ($plural_definition['plural_id'] ?? $key . '_plural')
989 || count($expected_forms) !== $rule->getCount()
990 || $parsed_forms !== $expected_forms
991 || $rule->defaultValueOf($parsed_forms) !== $expected
992 ) {
993 $po_mismatches[] = $key . ' (plural)';
994 }
995 }
996 foreach ($parsed?->getEntries() ?? [] as $parsed_entry) {
997 if ($parsed_entry->getContext() !== null || !in_array($parsed_entry->getId(), $expected_keys, true)) {
998 $po_mismatches[] = '(unexpected) ' . $parsed_entry->getId();
999 }
1000 }
1001 // must serve exactly these values, cleaned only where markup was warned about above
1002 $known_markup = ($markup_report[$reference_lang_key] ?? []) + ($markup_report[$lang_key] ?? []);
1003 // a plural message also carries the value of its singular key (plurals.json)
1004 $has_known_markup = static fn(string $message): bool => isset($known_markup[$message])
1005 || isset($known_markup[$plural_definitions[$message]['singular'] ?? '']);
1006 try {
1007 $compiled = $parsed === null ? null : (new ShippedTranslations($markup_policy))->compileShippedCatalog(
1008 $parsed,
1009 $module,
1010 static function (string $identifier) use (&$po_mismatches, $has_known_markup): void {
1011 if (!$has_known_markup(PluralFormKey::parse($identifier)[0] ?? $identifier)) {
1012 $po_mismatches[] = '(markup) ' . $identifier;
1013 }
1014 },
1015 static function (string $message) use (&$po_mismatches): void {
1016 $po_mismatches[] = '(build) ' . $message;
1017 }
1018 );
1019 } catch (RuntimeException $e) {
1020 $compiled = null;
1021 $po_mismatches[] = '(not compilable) ' . $e->getMessage();
1022 }
1023 foreach ($expected_keys as $key) {
1024 if (
1025 $compiled !== null
1026 && !$has_known_markup($key)
1027 && ($compiled['values'][$key] ?? '') !== ($entries[$key]['value'] ?? '')
1028 ) {
1029 $po_mismatches[] = $key . ' (build)';
1030 }
1031 }
1032
1033 $fuzzy_count = 0;
1034 foreach ($entries as $e) {
1035 if ($e['comment'] !== null && $is_fuzzy_marker($e['comment'])) {
1036 $fuzzy_count++;
1037 }
1038 }
1039
1040 $report[] = [
1041 'lang' => $lang_key,
1042 'entries' => $own_entry_counts[$lang_key],
1043 'fuzzy' => $fuzzy_count,
1044 'filled' => count($filled[$lang_key] ?? []),
1045 'po_ok' => $po_mismatches === [],
1046 'po_mismatches' => $po_mismatches,
1047 ];
1048}
1049
1050// entries: the language's own lines of the module; fuzzy: of those, marked "new variable";
1051// filled: reference keys the language lacks or has empty, written with the reference value as fuzzy
1052printf("%-6s %8s %8s %8s %8s\n", 'lang', 'entries', 'fuzzy', 'filled', 'po_ok');
1053$all_ok = true;
1054foreach ($report as $row) {
1055 printf("%-6s %8d %8d %8d %8s\n", $row['lang'], $row['entries'], $row['fuzzy'], $row['filled'], $row['po_ok'] ? 'yes' : 'NO');
1056 if (!$row['po_ok']) {
1057 $all_ok = false;
1058 fwrite(STDERR, " po_mismatches: " . $printable(implode(', ', $row['po_mismatches'])) . "\n");
1059 }
1060}
1061
1062echo "\n";
1063if (!$all_ok) {
1064 fwrite(STDERR, "FAILURE: at least one language did not round-trip correctly, see stderr above. Nothing was written.\n");
1065 exit(1);
1066}
1067
1068if (!is_dir($output_dir) && !@mkdir($output_dir, 0775, true) && !is_dir($output_dir)) {
1069 fwrite(STDERR, "FAILURE: cannot create $output_dir. Nothing was written.\n");
1070 exit(1);
1071}
1073foreach ($contents as $path => $content) {
1074 try {
1075 $write($path, $content);
1076 } catch (RuntimeException $e) {
1077 fwrite(STDERR, "FAILURE: {$e->getMessage()} - written already: " . ($written === [] ? 'nothing' : implode(', ', $written))
1078 . ". No .lang file was changed.\n");
1079 exit(1);
1080 }
1081 $written[] = basename($path);
1082}
1083echo "OK: all " . count($report) . " languages round-trip identically through PO ("
1084 . array_sum(array_column($report, 'filled')) . " entries filled from '$reference_lang_key', flagged fuzzy).\n";
1085
1086if (!$remove_from_lang) {
1087 exit(0);
1088}
1089
1090// --remove-from-lang: the module's .po files become its only source; the new contents were computed while reading
1093foreach ($read_files as $lang_key => $read) {
1094 $content = file_get_contents($read['path']);
1095 if ($content === false || hash('sha256', $content) !== $read['hash']) {
1096 $changed_files[] = $read['path'];
1097 continue;
1098 }
1099 if ($read['removed'] > 0) {
1100 $new_lang_contents[$lang_key] = [$read['new_content'], $read['removed']];
1101 }
1102}
1103if ($changed_files !== []) {
1104 fwrite(STDERR, "FAILURE: --remove-from-lang: no .lang file was changed (the .po/.pot files are written), these were"
1105 . " changed during the run or cannot be read again:\n " . implode("\n ", $changed_files) . "\n");
1106 exit(1);
1107}
1108
1109echo "\n";
1111foreach ($new_lang_contents as $lang_key => [$new_content, $removed]) {
1112 $path = $read_files[$lang_key]['path'];
1113 try {
1114 AtomicFileWriter::write($path, $new_content, $lang_dir);
1115 } catch (RuntimeException $e) {
1116 fwrite(STDERR, "FAILURE: --remove-from-lang: {$e->getMessage()} - the files listed above are changed already, the others not.\n");
1117 exit(1);
1118 }
1119 $removed_total += $removed;
1120 printf("removed %5d line(s) of '%s' from %s\n", $removed, $module, 'lang/' . basename($path));
1121}
1122echo "OK: removed $removed_total line(s) of module '$module' from " . count($new_lang_contents) . " .lang file(s).\n";
1123
1124exit(0);
$comment
Definition: buildRTE.php:72
$components
Writes a file so that concurrent readers only see the complete old or new content: a temporary file i...
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
One message of a TranslationCatalog (a msgctxt/msgid/msgstr block of a .po file).
The dated "not translated yet" marker of the legacy .lang files ("28 08 2012 new variable") in a ### ...
The single place that knows where the files of a module migrated to PO/MO live:
A form of a plural message of a migrated module in a flat identifier => value map (admin GUI rows,...
The plural rule of a language, as declared by the "Plural-Forms" header of a gettext catalog (nplural...
Definition: PluralForms.php:42
Compiles the messages of a migrated module into the .mo native gettext serves (see NativeGettext): th...
The HTML a translation value may contain, decided in one place for the shipped files (cleaned in Setu...
$body_lines
The lines of $content the installer reads (see LanguageFileParsing::cutHeader()).
$copies_own_value
Whether the own value of the plural message $key was copied into several forms (flagged fuzzy,...
$find_duplicate_keys
A legacy .lang file can define the same identifier twice for one module (uniqueness validation of the...
if($lang_files===[]) $per_language
foreach($plural_definitions as $key=> $definition) if( $plural_definitions !==[]) if($plural_problems !==[]) $language_names
foreach( $per_language as $lang_key=> $entries) if($invalid_encoding !==[]) if(array_sum(array_column( $read_files, 'lines'))===0) if(!isset($per_language[$reference_lang_key])) $reference_keys
if($remove_from_lang) $plural_config_file
$skip_unmigratable_keys
catch(InvalidArgumentException $e) $repo_root
$without_module_lines
$content without the lines of $module - exactly those $read_lines reports, and only behind the line "...
if(!isset($arguments[0])) if(count( $arguments) > 3) if(! $remove_from_lang &&isset($arguments[2]) &&!is_dir($arguments[2])) $module
$singular_form_index
The form that serves n = 1 if it serves no other n, else null.
foreach($lang_files as $file) if( $unsafe_files !==[]) if($files_without_marker !==[] && $remove_from_lang) foreach( $files_without_marker as $file) if($removal_problems !==[]) $invalid_encoding
$files_without_marker
foreach($per_language as $lang_key=> $entries) if( $unmigratable_keys !==[] &&! $skip_unmigratable_keys) if($unmigratable_keys !==[]) $reference_entries
$removal_target_problem
Why $output_argument is no place –remove-from-lang may rely on, or null: it must be the existing comp...
$identical_duplicate_report
if(! $all_ok) if(!is_dir($output_dir) &&! @mkdir($output_dir, 0775, true) &&!is_dir($output_dir)) $written
foreach($per_language as $lang_key=> $entries) $plural_problems
$read_file
Read once, so the lines and the hash checked by –remove-from-lang come from the same bytes.
$singular_form_of
The form of the plural message $key that gets the singular key's value, or null: only if the singular...
foreach( $per_language as $lang_key=> $entries) foreach($markup_messages as $message=> $lines) $own_entry_counts
if(is_file($english_lang_file)) if( $language_names===[]) foreach(array_keys($per_language) as $lang_key) $pot_path
$printable
Makes control characters (e.g.
$is_fuzzy_marker
Whether a ### comment is the dated "not translated yet" marker (-> "#, fuzzy") rather than a note (->...
if($reference_entries===[]) $markup_policy
foreach(array_slice($argv, 1) as $argument) $usage
foreach($duplicates as $lang_key=> $keys) if( $duplicate_report !==[]) if($identical_duplicate_report !==[]) $unmigratable_keys
if(! $remove_from_lang) $new_lang_contents
if(PHP_SAPI !=='cli')
Pilot conversion tool for the PO/MO migration.
$text
Definition: xapiexit.php:21
$token
Definition: xapitoken.php:69
exit
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...