ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
ShippedPoMerger.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
25use RuntimeException;
26
64final class ShippedPoMerger
65{
85 public static function merge(
86 LanguageFileDirectoryManager $language_file_directory_manager,
87 string $ilias_absolute_path,
88 string $lang_key,
89 ?string $client_data_dir,
90 string $backup_directory,
91 ?\Closure $after_module = null
92 ): array {
93 $result = [
94 'written' => [],
95 'skipped' => [],
96 'invalid_markup' => [],
97 'not_merged' => [],
98 'unwritten_database' => [],
99 'unmarked_overlay' => [],
100 ];
101 if ($client_data_dir === null) {
102 return $result;
103 }
104
105 $shipped_files = MigratedLanguageFileSync::findShippedModuleFiles($language_file_directory_manager, $ilias_absolute_path, $lang_key);
106 $backup_names = self::backupFileNames($shipped_files);
107 // the fixed lock order, see MigratedLanguageFileSync::withOverlayLock()
108 ksort($shipped_files, SORT_STRING);
109 foreach ($shipped_files as $module => $shipped_po) {
110 $module = (string) $module;
111 if (!MigratedLanguageFileSync::hasOverlay($language_file_directory_manager, $lang_key, $module, $client_data_dir)) {
112 continue;
113 }
114 if ($backup_names[$module] === null) {
115 $result['skipped'][$module] = sprintf('The shipped file name "%s" is not unique among the modules, it cannot be backed up.', basename($shipped_po));
116 continue;
117 }
119 $language_file_directory_manager,
120 $lang_key,
121 $module,
122 $client_data_dir,
123 static function () use ($language_file_directory_manager, $ilias_absolute_path, $lang_key, $module, $shipped_po, $client_data_dir, $backup_directory, $backup_names, $after_module): array {
124 try {
125 // the template is shared by all languages: its own lock, after the overlay lock
127 $language_file_directory_manager,
128 $module,
129 $client_data_dir,
130 static fn(): array => self::mergeModule(
131 $language_file_directory_manager,
132 $ilias_absolute_path,
133 $lang_key,
134 $module,
135 $shipped_po,
136 $client_data_dir,
137 rtrim($backup_directory, '/') . '/' . $backup_names[$module]
138 )
139 );
140 } catch (RuntimeException|\InvalidArgumentException $e) {
141 return ['skipped' => $e->getMessage()];
142 }
143 if ($outcome['written'] && $after_module !== null) {
144 // the files are written - a failing database write must not stop the others
145 try {
146 $after_module($module, $outcome['merged'], $outcome['merged_remarks']);
147 } catch (\Throwable $t) {
148 $outcome['database_error'] = $t->getMessage();
149 }
150 }
151 return $outcome;
152 },
153 $ilias_absolute_path
154 );
155
156 if (isset($outcome['skipped'])) {
157 $result['skipped'][$module] = $outcome['skipped'];
158 continue;
159 }
160 if ($outcome['invalid_markup'] !== []) {
161 $result['invalid_markup'][$module] = $outcome['invalid_markup'];
162 }
163 if ($outcome['not_merged'] !== []) {
164 $result['not_merged'][$module] = $outcome['not_merged'];
165 }
166 if ($outcome['written']) {
167 $result['written'][$module] = $shipped_po;
168 }
169 if (isset($outcome['database_error'])) {
170 $result['unwritten_database'][$module] = $outcome['database_error'];
171 }
172 if ($outcome['unmarked'] !== null) {
173 $result['unmarked_overlay'][$module] = $outcome['unmarked'];
174 }
175 }
176
177 return $result;
178 }
179
190 public static function backupShippedPoFiles(
191 LanguageFileDirectoryManager $language_file_directory_manager,
192 string $ilias_absolute_path,
193 string $lang_key,
194 string $directory,
195 string $confine_to_directory
196 ): array {
197 $shipped_files = MigratedLanguageFileSync::findShippedModuleFiles($language_file_directory_manager, $ilias_absolute_path, $lang_key);
198 $failed = [];
199 foreach (self::backupFileNames($shipped_files) as $module => $name) {
200 try {
201 if ($name === null) {
202 throw new RuntimeException('file name not unique');
203 }
204 self::backup($shipped_files[$module], rtrim($directory, '/') . '/' . $name, $confine_to_directory);
205 } catch (RuntimeException) {
206 $failed[] = (string) $module;
207 }
208 }
209
210 return $failed;
211 }
212
222 public static function findShippedChangesSinceBackup(
223 LanguageFileDirectoryManager $language_file_directory_manager,
224 string $ilias_absolute_path,
225 string $lang_key,
226 string $directory
227 ): array {
228 $shipped_files = MigratedLanguageFileSync::findShippedModuleFiles($language_file_directory_manager, $ilias_absolute_path, $lang_key);
229 $result = ['changes' => [], 'without_backup' => []];
230 foreach (self::backupFileNames($shipped_files) as $module => $name) {
231 $module = (string) $module;
232 $backup = $name === null ? null : rtrim($directory, '/') . '/' . $name;
233 try {
234 if ($backup === null || !is_file($backup) || !is_readable($backup)) {
235 throw new RuntimeException('no backup');
236 }
237 $former = self::valuesOf(MigratedLanguageFileSync::loadShippedModuleEntries($backup, $module));
238 $current = self::valuesOf(MigratedLanguageFileSync::loadShippedModuleEntries($shipped_files[$module], $module));
239 } catch (RuntimeException) {
240 $result['without_backup'][] = $module;
241 continue;
242 }
243 $changes = array_diff_assoc($current, $former);
244 if ($changes !== []) {
245 $result['changes'][$module] = $changes;
246 }
247 }
248
249 return $result;
250 }
251
259 private static function mergeModule(
260 LanguageFileDirectoryManager $language_file_directory_manager,
261 string $ilias_absolute_path,
262 string $lang_key,
263 string $module,
264 string $shipped_po,
265 string $client_data_dir,
266 string $backup_file
267 ): array {
268 $outcome = [
269 'written' => false,
270 'merged' => [],
271 'merged_remarks' => [],
272 'invalid_markup' => [],
273 'not_merged' => [],
274 'unmarked' => null,
275 ];
276 // read under the lock - it may have changed since hasOverlay()
277 $overlay = MigratedLanguageFileSync::readOverlayCatalog($language_file_directory_manager, $lang_key, $module, $client_data_dir, $ilias_absolute_path);
278 $directory = MigratedLanguageFileSync::findDirectory($language_file_directory_manager, $module);
279 if ($overlay === null || $directory === null) {
280 return $outcome;
281 }
282 $shipped = TranslationCatalog::fromPoFile($shipped_po);
283 // an overlay written under another plural rule: its forms by meaning, see withPluralRuleOf()
284 $overlay = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, $module);
285 $overlay_entries = MigratedLanguageFileSync::moduleEntries($overlay, $module);
286
287 // Everything that has to be writable, checked before anything is written
288 self::assertWritableFile($shipped_po, $ilias_absolute_path);
289 self::assertWritableFile($backup_file, $ilias_absolute_path);
290 $template_file = MigratedLanguageFilePaths::shippedTemplatePath($ilias_absolute_path, $directory);
291 $template = null;
292 foreach ($overlay_entries as $identifier => $entry) {
293 if (
294 !MigratedLanguageFileSync::isRemarkOnly($entry)
295 && PluralFormKey::parse((string) $identifier) === null
296 && MigratedLanguageFileSync::findModuleEntry($shipped, $module, (string) $identifier) === null
297 ) {
298 if (!is_file($template_file)) {
299 throw new RuntimeException(sprintf('The template "%s" for the entries added locally is missing.', $template_file));
300 }
301 self::assertWritableFile($template_file, $ilias_absolute_path);
302 $template = TranslationCatalog::fromPoFile($template_file);
303 break;
304 }
305 }
306
307 $plural_forms = MigratedLanguageFileSync::pluralFormsOf($shipped);
308 $policy = new TranslationMarkupPolicy();
309 $template_changed = false;
310 foreach ($overlay_entries as $identifier => $overlay_entry) {
311 $identifier = (string) $identifier;
312 $shipped_entry = MigratedLanguageFileSync::findModuleEntry($shipped, $module, $identifier);
313 $remark = LocalChangeComments::getRemark($overlay_entry);
314 $has_value = !MigratedLanguageFileSync::isRemarkOnly($overlay_entry);
315
316 if ($has_value) {
317 // a new identifier shaped like a plural form (see PluralFormKey) would be read as one: not added
318 $message = $shipped_entry === null && PluralFormKey::parse($identifier) !== null
319 ? null
320 : self::mergedMessage($overlay_entry, $shipped_entry, $template, $module, $plural_forms);
321 if ($message === null) {
322 $outcome['not_merged'][] = $identifier;
323 continue;
324 }
325 // only a value the shipped .po does not carry yet is checked, see findInvalidChangedValues()
326 $shipped_values = $shipped_entry === null ? [] : MigratedLanguageFileSync::flatValuesOf($shipped_entry, $plural_forms);
327 $invalid = $policy->findInvalidChangedValues(self::flatValues($identifier, $message), $shipped_values, $shipped_values);
328 if ($invalid !== []) {
329 $outcome['invalid_markup'] += $invalid;
330 continue;
331 }
332
333 $target = $shipped_entry ?? new TranslationEntry(null, $identifier);
334 if ($message['plural_id'] === null) {
335 $target->translate($message['forms'][0]);
336 } else {
337 $target->setPlural($message['plural_id'], $message['forms']);
338 }
339 $target->removeFlag('fuzzy');
340 if ($shipped_entry === null) {
341 $shipped->addInIdOrder($target);
342 $template_changed = self::addToTemplate($template, $module, $identifier, $message['plural_id']) || $template_changed;
343 }
344 $outcome['merged'][] = $identifier;
345 $shipped_entry = $target;
346 }
347
348 if ($remark !== null && $remark !== '') {
349 if ($shipped_entry === null) {
350 // the remark of an entry without value that is not shipped - nothing to put it on
351 continue;
352 }
353 // as stored: compare the cleaned remark
354 $notes = array_map('trim', MigratedLanguageFileSync::shippedNotesOf($shipped_entry));
355 if (!in_array(trim(TranslationEntry::plainComment($remark)), $notes, true)) {
356 $shipped_entry->addTranslatorNote($remark);
357 }
358 $outcome['merged_remarks'][] = $identifier;
359 }
360 }
361
362 if ($outcome['merged'] === [] && $outcome['merged_remarks'] === []) {
363 return $outcome;
364 }
365
366 $po_content = self::checkedPoContent($shipped, true);
367 $template_content = $template_changed && $template !== null ? self::checkedPoContent($template, false) : null;
368 $previous_content = @file_get_contents($shipped_po);
369 if ($previous_content === false) {
370 throw new RuntimeException(sprintf('Could not read "%s".', $shipped_po));
371 }
372 if ($po_content === $previous_content && $template_content === null) {
373 // taken over before (the overlay keeps it until "setup update"): nothing to write - only
374 // a marking that failed then is made up for (a marked entry stays as it is)
375 $outcome['unmarked'] = self::markMerged($language_file_directory_manager, $lang_key, $module, $outcome['merged'], $client_data_dir, $ilias_absolute_path);
376 $outcome['merged'] = [];
377 $outcome['merged_remarks'] = [];
378 return $outcome;
379 }
380 // confined to the ILIAS directory: a symbolic link out of it is refused, nothing is written
381 self::backup($shipped_po, $backup_file, $ilias_absolute_path, $previous_content);
382 if ($template_content !== null) {
383 // the template first: an additional entry there is harmless should the .po fail
384 AtomicFileWriter::write($template_file, $template_content, $ilias_absolute_path);
385 }
386 AtomicFileWriter::write($shipped_po, $po_content, $ilias_absolute_path);
387 $outcome['written'] = true;
388 // the overlay keeps its values and the taken-over entries are marked, see the class docblock
389 $outcome['unmarked'] = self::markMerged($language_file_directory_manager, $lang_key, $module, $outcome['merged'], $client_data_dir, $ilias_absolute_path);
390
391 return $outcome;
392 }
393
401 private static function markMerged(
402 LanguageFileDirectoryManager $language_file_directory_manager,
403 string $lang_key,
404 string $module,
405 array $identifiers,
406 string $client_data_dir,
407 string $ilias_absolute_path
408 ): ?string {
409 try {
410 MigratedLanguageFileSync::markMergedIntoShipped(
411 $language_file_directory_manager,
412 $lang_key,
413 $module,
414 $identifiers,
415 $client_data_dir,
416 $ilias_absolute_path
417 );
418 } catch (RuntimeException|\InvalidArgumentException $e) {
419 return $e->getMessage();
420 }
421
422 return null;
423 }
424
433 private static function mergedMessage(
434 TranslationEntry $overlay_entry,
435 ?TranslationEntry $shipped_entry,
436 ?TranslationCatalog $template,
437 string $module,
438 PluralForms $plural_forms
439 ): ?array {
440 if (!$overlay_entry->isPlural() && !$shipped_entry?->isPlural()) {
441 return ['plural_id' => null, 'forms' => [$overlay_entry->getTranslation()]];
442 }
443 if ($overlay_entry->isPlural() && $shipped_entry !== null && !$shipped_entry->isPlural()) {
444 return null;
445 }
446
447 if ($overlay_entry->isPlural()) {
448 $forms = array_values(MigratedLanguageFileSync::flatValuesOf($overlay_entry, $plural_forms));
449 } else {
450 $forms = array_values(MigratedLanguageFileSync::flatValuesOf($shipped_entry, $plural_forms));
451 $forms[$plural_forms->defaultFormIndex()] = $overlay_entry->getTranslation();
452 }
453 if ($forms[0] === '' && implode('', $forms) !== '') {
454 return null;
455 }
456 $template_entry = $template === null ? null : MigratedLanguageFileSync::findModuleEntry($template, $module, $overlay_entry->getId());
457
458 return [
459 'plural_id' => $shipped_entry?->getPluralId()
460 ?? $template_entry?->getPluralId()
461 ?? $overlay_entry->getId() . '_plural',
462 'forms' => $forms,
463 ];
464 }
465
472 private static function addToTemplate(?TranslationCatalog $template, string $module, string $identifier, ?string $plural_id): bool
473 {
474 if ($template === null || MigratedLanguageFileSync::findModuleEntry($template, $module, $identifier) !== null) {
475 return false;
476 }
477 $entry = new TranslationEntry(null, $identifier);
478 if ($plural_id !== null) {
479 $entry->setPlural($plural_id, ['', '']);
480 }
481 $template->addInIdOrder($entry);
482
483 return true;
484 }
485
490 private static function flatValues(string $identifier, array $message): array
491 {
492 if ($message['plural_id'] === null) {
493 return [$identifier => $message['forms'][0]];
494 }
495 $values = [];
496 foreach ($message['forms'] as $form => $value) {
497 $values[PluralFormKey::of($identifier, $form)] = $value;
498 }
499
500 return $values;
501 }
502
509 private static function checkedPoContent(TranslationCatalog $catalog, bool $compile): string
510 {
511 $content = $catalog->toPoString();
512 if (!mb_check_encoding($content, 'UTF-8')) {
513 throw new RuntimeException('The merged file would not be valid UTF-8.');
514 }
515 TranslationCatalog::fromPoString($content);
516 if ($compile) {
517 $catalog->toMoString();
518 }
519
520 return $content;
521 }
522
529 private static function backup(string $shipped_po, string $backup_file, string $confine_to_directory, ?string $content = null): void
530 {
531 $content ??= @file_get_contents($shipped_po);
532 if ($content === false) {
533 throw new RuntimeException(sprintf('Could not read "%s".', $shipped_po));
534 }
535 if (!is_dir(dirname($backup_file))) {
536 throw new RuntimeException(sprintf('The backup directory "%s" does not exist.', dirname($backup_file)));
537 }
538 AtomicFileWriter::write($backup_file, $content, $confine_to_directory);
539 }
540
547 private static function assertWritableFile(string $file, string $confine_to_directory): void
548 {
549 $root = realpath($confine_to_directory);
550 $directory = realpath(dirname($file));
551 if (
552 $root === false
553 || $directory === false
554 || ($directory !== $root && !str_starts_with($directory, rtrim($root, '/') . '/'))
555 || !is_dir(dirname($file))
556 || !is_writable(dirname($file))
557 || is_link($file)
558 || (file_exists($file) && (!is_file($file) || !is_writable($file)))
559 ) {
560 throw new RuntimeException(sprintf('"%s" is not writable or does not resolve to a path below "%s".', $file, $confine_to_directory));
561 }
562 }
563
571 private static function backupFileNames(array $shipped_files): array
572 {
573 $counts = array_count_values(array_map('basename', $shipped_files));
574 $names = [];
575 foreach ($shipped_files as $module => $file) {
576 $names[(string) $module] = $counts[basename($file)] === 1 ? basename($file) : null;
577 }
578
579 return $names;
580 }
581
586 private static function valuesOf(array $entries): array
587 {
588 return array_map(static fn(array $entry): string => $entry['value'], $entries);
589 }
590}
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
addInIdOrder(TranslationEntry $entry)
add() at the position of $entry's id: before the first entry whose id sorts after it (strcmp(),...
toMoString()
Deterministic: the same catalog always compiles to the same bytes, so an existing ....
One message of a TranslationCatalog (a msgctxt/msgid/msgstr block of a .po file).
Reads the shipped .po of a migrated module and maintains its per-installation overlay: the ....
static withTemplateLock(LanguageFileDirectoryManager $language_file_directory_manager, string $module, ?string $client_data_dir, \Closure $callback)
Runs $callback while holding the lock of $module's template (.pot, see MigratedLanguageFilePaths::tem...
static findShippedModuleFiles(LanguageFileDirectoryManager $language_file_directory_manager, string $ilias_absolute_path, string $lang_key)
Every module migrated for $lang_key (its shipped .po exists, parseable or not) with the path of that ...
static hasOverlay(LanguageFileDirectoryManager $language_file_directory_manager, string $lang_key, string $module, ?string $client_data_dir)
Whether $module/$lang_key has an overlay (anything at the place of its .po or current),...
static withOverlayLock(?LanguageFileDirectoryManager $language_file_directory_manager, string $lang_key, string $module, ?string $client_data_dir, \Closure $callback, ?string $ilias_absolute_path=null)
Runs $callback while holding an exclusive flock() on the lock file of the overlay of $module/$lang_ke...
The plural rule of a language, as declared by the "Plural-Forms" header of a gettext catalog (nplural...
Definition: PluralForms.php:42
The "merge" maintenance action of developer mode (LANGMODE) for PO-maintained modules,...
static assertWritableFile(string $file, string $confine_to_directory)
static findShippedChangesSinceBackup(LanguageFileDirectoryManager $language_file_directory_manager, string $ilias_absolute_path, string $lang_key, string $directory)
What the shipped .po of every migrated module changed since its backup in $directory (see backupShipp...
static markMerged(LanguageFileDirectoryManager $language_file_directory_manager, string $lang_key, string $module, array $identifiers, string $client_data_dir, string $ilias_absolute_path)
MigratedLanguageFileSync::markMergedIntoShipped() for the files just written (or written by an earlie...
static merge(LanguageFileDirectoryManager $language_file_directory_manager, string $ilias_absolute_path, string $lang_key, ?string $client_data_dir, string $backup_directory, ?\Closure $after_module=null)
static backupShippedPoFiles(LanguageFileDirectoryManager $language_file_directory_manager, string $ilias_absolute_path, string $lang_key, string $directory, string $confine_to_directory)
Copies the shipped .po of every module migrated for $lang_key into $directory under its shipped file ...
static checkedPoContent(TranslationCatalog $catalog, bool $compile)
$catalog as .po content, after making sure it can be read back and, for a language's ....
static flatValues(string $identifier, array $message)
static backup(string $shipped_po, string $backup_file, string $confine_to_directory, ?string $content=null)
Copies $shipped_po (or $content) to $backup_file atomically, confined to $confine_to_directory (see A...
static mergeModule(LanguageFileDirectoryManager $language_file_directory_manager, string $ilias_absolute_path, string $lang_key, string $module, string $shipped_po, string $client_data_dir, string $backup_file)
merge() for one module, under its overlay lock and the template lock of the module.
static addToTemplate(?TranslationCatalog $template, string $module, string $identifier, ?string $plural_id)
Adds the locally added entry $identifier to the template without msgstr (a plural message with two em...
static mergedMessage(TranslationEntry $overlay_entry, ?TranslationEntry $shipped_entry, ?TranslationCatalog $template, string $module, PluralForms $plural_forms)
The message $overlay_entry makes of its shipped entry: a singular value, or every form of a plural me...
static backupFileNames(array $shipped_files)
The backup name of each shipped .po of $shipped_files (module => path): its file name,...
if(!isset($arguments[0])) if(count( $arguments) > 3) if(! $remove_from_lang &&isset($arguments[2]) &&!is_dir($arguments[2])) $module
try
if(!file_exists('../ilias.ini.php'))