diff --git a/.gitignore b/.gitignore
index 218c33abd964..31e4fd2ef157 100755
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,8 @@
/db_template_writer.php
/components/ILIAS/Database/sql/dbupdate_custom.php
/install_client.php
+/lang/customizing/*.po
+/lang/customizing/ilias_*.lang
# Directories
/public
diff --git a/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php b/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php
index 516aae78b4c1..cf8941f1c3e5 100644
--- a/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php
+++ b/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php
@@ -333,7 +333,7 @@ private function mockUserLanguageGerman(): void
$ilDB->method('query')->willReturnCallback(function ($query): ilDBStatement {
$statement = $this->createMock(ilDBStatement::class);
- if (str_contains($query, 'SELECT * FROM lng_modules')) {
+ if (str_contains($query, 'FROM lng_modules')) {
$statement->method('numRows')->willReturn(1);
$statement->method('fetchRow')->willReturn([
'lang_array' => serialize([
diff --git a/components/ILIAS/Component/Component.php b/components/ILIAS/Component/Component.php
index c0cea15a0749..606515ca945b 100644
--- a/components/ILIAS/Component/Component.php
+++ b/components/ILIAS/Component/Component.php
@@ -20,6 +20,9 @@
namespace ILIAS;
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory;
+
class Component implements Component\Component
{
public function init(
@@ -53,5 +56,9 @@ public function init(
$internal[Component\Resource\PublicAssetManager::class] = static fn() =>
new Component\Resource\PublicAssetManager();
+ $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory(
+ $this,
+ 'cmps'
+ );
}
}
diff --git a/components/ILIAS/Component/lang/cmps.pot b/components/ILIAS/Component/lang/cmps.pot
new file mode 100644
index 000000000000..a95f41569c52
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps.pot
@@ -0,0 +1,228 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"MIME-Version: 1.0\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr ""
+
+msgid "cmps_active"
+msgstr ""
+
+msgid "cmps_add_new_rank"
+msgstr ""
+
+msgid "cmps_available"
+msgstr ""
+
+msgid "cmps_available_version"
+msgstr ""
+
+msgid "cmps_basic_files"
+msgstr ""
+
+msgid "cmps_class_file"
+msgstr ""
+
+msgid "cmps_component"
+msgstr ""
+
+msgid "cmps_configure"
+msgstr ""
+
+msgid "cmps_current_db_version"
+msgstr ""
+
+msgid "cmps_current_version"
+msgstr ""
+
+msgid "cmps_database"
+msgstr ""
+
+msgid "cmps_db_update"
+msgstr ""
+
+msgid "cmps_deactivate"
+msgstr ""
+
+msgid "cmps_detailed_information"
+msgstr ""
+
+msgid "cmps_dir"
+msgstr ""
+
+msgid "cmps_disable_creation"
+msgstr ""
+
+msgid "cmps_enable_creation"
+msgstr ""
+
+msgid "cmps_file_version"
+msgstr ""
+
+msgid "cmps_group"
+msgstr ""
+
+msgid "cmps_id"
+msgstr ""
+
+msgid "cmps_ilias_max_version"
+msgstr ""
+
+msgid "cmps_ilias_min_version"
+msgstr ""
+
+msgid "cmps_inactive"
+msgstr ""
+
+msgid "cmps_install"
+msgstr ""
+
+msgid "cmps_is_active"
+msgstr ""
+
+msgid "cmps_is_installed"
+msgstr ""
+
+msgid "cmps_lang_files"
+msgstr ""
+
+msgid "cmps_lang_prefix"
+msgstr ""
+
+msgid "cmps_languages"
+msgstr ""
+
+msgid "cmps_main_dir"
+msgstr ""
+
+msgid "cmps_missing"
+msgstr ""
+
+msgid "cmps_module"
+msgstr ""
+
+msgid "cmps_must_installed"
+msgstr ""
+
+msgid "cmps_name"
+msgstr ""
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr ""
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr ""
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr ""
+
+msgid "cmps_needs_update"
+msgstr ""
+
+msgid "cmps_needs_upgrade"
+msgstr ""
+
+msgid "cmps_no_db_update_file_available"
+msgstr ""
+
+msgid "cmps_no_language_file_available"
+msgstr ""
+
+msgid "cmps_plugin"
+msgstr ""
+
+msgid "cmps_plugin_activated"
+msgstr ""
+
+msgid "cmps_plugin_db_prefixes"
+msgstr ""
+
+msgid "cmps_plugin_deactivated"
+msgstr ""
+
+msgid "cmps_plugin_deinstalled"
+msgstr ""
+
+msgid "cmps_plugin_file"
+msgstr ""
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr ""
+
+msgid "cmps_plugin_slot"
+msgstr ""
+
+msgid "cmps_plugin_uninstalled"
+msgstr ""
+
+msgid "cmps_plugin_updated"
+msgstr ""
+
+msgid "cmps_plugins"
+msgstr ""
+
+msgid "cmps_refresh"
+msgstr ""
+
+msgid "cmps_refresh_lng"
+msgstr ""
+
+msgid "cmps_rep_object"
+msgstr ""
+
+msgid "cmps_repository_object_types"
+msgstr ""
+
+msgid "cmps_responsible"
+msgstr ""
+
+msgid "cmps_responsible_mail"
+msgstr ""
+
+msgid "cmps_save_options"
+msgstr ""
+
+msgid "cmps_service"
+msgstr ""
+
+msgid "cmps_show_details"
+msgstr ""
+
+msgid "cmps_slots"
+msgstr ""
+
+msgid "cmps_status"
+msgstr ""
+
+msgid "cmps_supports_cli_setup"
+msgstr ""
+
+msgid "cmps_supports_export"
+msgstr ""
+
+msgid "cmps_supports_learning_progress"
+msgstr ""
+
+msgid "cmps_uninstall"
+msgstr ""
+
+msgid "cmps_uninstall_confirm"
+msgstr ""
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr ""
+
+msgid "cmps_update"
+msgstr ""
+
+msgid "cmps_version"
+msgstr ""
+
+msgid "database_is_uptodate"
+msgstr ""
+
+msgid "no_changes"
+msgstr ""
diff --git a/components/ILIAS/Component/lang/cmps_ar.po b/components/ILIAS/Component/lang/cmps_ar.po
new file mode 100644
index 000000000000..e389b24c2e9e
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_ar.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ar\n"
+"Language-Team: Arabic\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: 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))));\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "تفعيل"
+
+msgid "cmps_active"
+msgstr "فعال"
+
+msgid "cmps_add_new_rank"
+msgstr "الوضعية في قائمة اضافة-عنصر-جديد"
+
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "الدليل"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+msgid "cmps_group"
+msgstr "مجموعة"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+msgid "cmps_lang_prefix"
+msgstr "بادئة متغيرات اللغة"
+
+msgid "cmps_languages"
+msgstr "Languages"
+
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+msgid "cmps_missing"
+msgstr "Missing"
+
+msgid "cmps_module"
+msgstr "وحدة"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "لا يوجد ملف لغة"
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "قائمة الاضافات"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "الاضافات"
+
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "نوع عنصر المكتبة"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "حفظ الخيارات"
+
+msgid "cmps_service"
+msgstr "خدمة"
+
+msgid "cmps_show_details"
+msgstr "عرض التفاصيل"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Update"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "The database is up to date."
+
+msgid "no_changes"
+msgstr "No changes"
diff --git a/components/ILIAS/Component/lang/cmps_bg.po b/components/ILIAS/Component/lang/cmps_bg.po
new file mode 100644
index 000000000000..f47e61318f8c
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_bg.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: bg\n"
+"Language-Team: Bulgarian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Активиране"
+
+msgid "cmps_active"
+msgstr "Активиран"
+
+msgid "cmps_add_new_rank"
+msgstr "Позиция в списъка за добавяне на нови обекти"
+
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Основни файлове"
+
+msgid "cmps_class_file"
+msgstr "Файл на класа"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Конфигуриране"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr " Текуща версия"
+
+msgid "cmps_database"
+msgstr "База данни"
+
+msgid "cmps_db_update"
+msgstr "Скрипт за обновяване на базата данни"
+
+msgid "cmps_deactivate"
+msgstr "Деактивиране"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Директория"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Активиране на създаването"
+
+msgid "cmps_file_version"
+msgstr "Актуализиране на версията на файла"
+
+msgid "cmps_group"
+msgstr "Група"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. Версия на ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. Версия на ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+msgid "cmps_install"
+msgstr "Инсталиране"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Езикови файлове"
+
+msgid "cmps_lang_prefix"
+msgstr " Префикс на езиковите променливи"
+
+msgid "cmps_languages"
+msgstr "Езици"
+
+msgid "cmps_main_dir"
+msgstr "Основна директория"
+
+msgid "cmps_missing"
+msgstr "Пропускане"
+
+msgid "cmps_module"
+msgstr "Module"
+
+msgid "cmps_must_installed"
+msgstr "Компонентът трябва да е инсталиран."
+
+msgid "cmps_name"
+msgstr "Име"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Тази версия на плъгина работи само с по-нови версии на ILIAS. Моля, надградете ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Тази версия на ILIAS се нуждае от по-нова версия на плъгина. Моля, актуализирайте плъгина."
+
+msgid "cmps_needs_update"
+msgstr "Необходима е актуализация."
+
+msgid "cmps_needs_upgrade"
+msgstr "Инсталираната в момента версия на плъгина е по-стара от предишната. Моля, актуализирайте плъгина."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Няма наличен скрипт за актуализация на БД."
+
+msgid "cmps_no_language_file_available"
+msgstr "Няма наличен езиков файл."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Плъгинът е активиран."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr " Префикси на таблици за БД"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Плъгинът е деактивиран."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Файл на приставка"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Префикси на езика на приставката"
+
+msgid "cmps_plugin_slot"
+msgstr "Плъгин слот"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Плъгинът е деинсталиран."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr " Тип на обекта на хранилището"
+
+msgid "cmps_repository_object_types"
+msgstr "Типове обекти на хранилището"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Запазване на опции"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Покажи подробности"
+
+msgid "cmps_slots"
+msgstr "Места"
+
+msgid "cmps_status"
+msgstr "Статус"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Деинсталиране"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Със сигурност ли искате да деинсталирате приставката \"%s\"?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Плъгинът \"%1$s\" понастоящем не може да бъде активиран (\"%2$s\"). В това състояние плъгините не могат да бъдат деинсталирани напълно. Тъй като се възстановява само интеграцията на ядрото, потребителските данни на плъгина могат да останат. Искате ли да продължите с частичното деинсталиране?"
+
+msgid "cmps_update"
+msgstr "Актуализация"
+
+msgid "cmps_version"
+msgstr "Версия"
+
+msgid "database_is_uptodate"
+msgstr "Базата данни е актуална"
+
+msgid "no_changes"
+msgstr "Няма промени"
diff --git a/components/ILIAS/Component/lang/cmps_cs.po b/components/ILIAS/Component/lang/cmps_cs.po
new file mode 100644
index 000000000000..c4ee2133f834
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_cs.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: cs\n"
+"Language-Team: Czech\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktivovat"
+
+msgid "cmps_active"
+msgstr "Aktivní"
+
+msgid "cmps_add_new_rank"
+msgstr "Pozice v seznamu \"Přidat nový objekt\""
+
+msgid "cmps_available"
+msgstr "Dostupné"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Základní soubory"
+
+msgid "cmps_class_file"
+msgstr "Soubor třídy"
+
+msgid "cmps_component"
+msgstr "Komponenta"
+
+msgid "cmps_configure"
+msgstr "Konfigurovat"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Aktuální verze"
+
+msgid "cmps_database"
+msgstr "Databáze"
+
+msgid "cmps_db_update"
+msgstr "Skript aktualizace DB"
+
+msgid "cmps_deactivate"
+msgstr "Deaktivovat"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Adresář"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Zapnout tvorbu"
+
+msgid "cmps_file_version"
+msgstr "Verze akualizačního souboru"
+
+msgid "cmps_group"
+msgstr "Skupina"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. verze ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. verze ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Neaktivní"
+
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Soubory jazyků"
+
+msgid "cmps_lang_prefix"
+msgstr "Označení jazykových proměnných"
+
+msgid "cmps_languages"
+msgstr "Jazyky"
+
+msgid "cmps_main_dir"
+msgstr "Hlavní adresář"
+
+msgid "cmps_missing"
+msgstr "Chybí"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+msgid "cmps_must_installed"
+msgstr "Komponenta musí být instalována."
+
+msgid "cmps_name"
+msgstr "Název"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin."
+
+msgid "cmps_needs_update"
+msgstr "Vyžaduje aktualizaci."
+
+msgid "cmps_needs_upgrade"
+msgstr "Verze pluginu, kterou instalujete, je starší než nainstalovaná verze. Aktualizujte prosím plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Není dostupný žádný skript aktualizace DB."
+
+msgid "cmps_no_language_file_available"
+msgstr "Žádný jazykový soubor není dostupný."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Plugin byl aktivován."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Označení tabulek DB"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Plugin byl deaktivován."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Soubor pluginu"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Označení jazykových pluginů"
+
+msgid "cmps_plugin_slot"
+msgstr "Slot pluginu"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Plugin byl odinstalován."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Pluginy"
+
+msgid "cmps_refresh"
+msgstr "Obnovení"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Typ objektu úložiště"
+
+msgid "cmps_repository_object_types"
+msgstr "Typy objektů úložiště"
+
+msgid "cmps_responsible"
+msgstr "Zodpovědný"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Uložit nastavení"
+
+msgid "cmps_service"
+msgstr "Služba"
+
+msgid "cmps_show_details"
+msgstr "Zobrazit detaily"
+
+msgid "cmps_slots"
+msgstr "Sloty"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Odinstalovat"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Opravdu chcete odinstalovat plugin „%s“?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Plugin „%1$s“ nelze v současné době aktivovat („%2$s“). V tomto stavu nelze pluginy zcela odinstalovat. Vzhledem k tomu, že je vrácena pouze integrace jádra, mohou zůstat zachována vlastní data pluginu. Chcete pokračovat v částečném odinstalování?"
+
+msgid "cmps_update"
+msgstr "Akualizovat"
+
+msgid "cmps_version"
+msgstr "Verze"
+
+msgid "database_is_uptodate"
+msgstr "Tato databáze je aktuální."
+
+msgid "no_changes"
+msgstr "Žádné změny"
diff --git a/components/ILIAS/Component/lang/cmps_da.po b/components/ILIAS/Component/lang/cmps_da.po
new file mode 100644
index 000000000000..4820de0280a5
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_da.po
@@ -0,0 +1,256 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: da\n"
+"Language-Team: Danish\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktiver"
+
+msgid "cmps_active"
+msgstr "Aktiv"
+
+msgid "cmps_add_new_rank"
+msgstr "Position i opret rullemenu"
+
+msgid "cmps_available"
+msgstr "Tilgængelig"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basisfiler"
+
+msgid "cmps_class_file"
+msgstr "Klassefiler"
+
+msgid "cmps_component"
+msgstr "Komponent"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Nuværende version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "Database opdaterings script"
+
+msgid "cmps_deactivate"
+msgstr "Deaktiver"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Mappe"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Opdater filversion"
+
+msgid "cmps_group"
+msgstr "Gruppe"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maks ILIAS version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min ILIAS version"
+
+msgid "cmps_inactive"
+msgstr "Inaktiv"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Sprogfiler"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefix for sprogvariable"
+
+msgid "cmps_languages"
+msgstr "Sprog"
+
+msgid "cmps_main_dir"
+msgstr "Rod folder"
+
+msgid "cmps_missing"
+msgstr "Mangler"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Navn"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Denne version af pluginet kører kun på en nyerere version af ILIAS. Du skal opgadere ILIAS før du kan anvende dette."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Denne ILIAS version kræver en nyerere plugin. Opgrader venligst pluginet."
+
+msgid "cmps_needs_update"
+msgstr "Kræver opdatering"
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Intet database script tilgængeligt"
+
+msgid "cmps_no_language_file_available"
+msgstr "Ingen sprogfil tilgængelig"
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Plugin blev aktiveret."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Database tabel prefix"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Plugin er deaktiveret."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin fil"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin sprog prefixer"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin plads"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Opfrisk"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Oversigts objekttype"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Ansvarlig"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Gem indstillinger"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Vis detaljer"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Opdater"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "Databasen er opdateret"
+
+msgid "no_changes"
+msgstr "Ingen ændringer"
diff --git a/components/ILIAS/Component/lang/cmps_de.po b/components/ILIAS/Component/lang/cmps_de.po
new file mode 100644
index 000000000000..83829b78f52d
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_de.po
@@ -0,0 +1,231 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: de\n"
+"Language-Team: German\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktivieren"
+
+msgid "cmps_active"
+msgstr "Aktiv"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in der Auswahlliste für hinzuzufügende Objekte"
+
+msgid "cmps_available"
+msgstr "Verfügbar"
+
+msgid "cmps_available_version"
+msgstr "Verfügbare Version"
+
+msgid "cmps_basic_files"
+msgstr "Basis-Dateien"
+
+msgid "cmps_class_file"
+msgstr "Klassen-Datei"
+
+msgid "cmps_component"
+msgstr "Komponente"
+
+msgid "cmps_configure"
+msgstr "Konfigurieren"
+
+msgid "cmps_current_db_version"
+msgstr "Aktuelle DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Aktuelle Version"
+
+msgid "cmps_database"
+msgstr "Datenbank"
+
+msgid "cmps_db_update"
+msgstr "Skript für Datenbankaktualisierung"
+
+msgid "cmps_deactivate"
+msgstr "Deaktivieren"
+
+msgid "cmps_detailed_information"
+msgstr "Detaillierte Informationen"
+
+msgid "cmps_dir"
+msgstr "Verzeichnis"
+
+msgid "cmps_disable_creation"
+msgstr "Erstellung ausschalten"
+
+msgid "cmps_enable_creation"
+msgstr "Erstellung einschalten"
+
+msgid "cmps_file_version"
+msgstr "Dateiversion aktualisieren"
+
+msgid "cmps_group"
+msgstr "Gruppe"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maximale ILIAS-Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Minimale ILIAS-Version"
+
+msgid "cmps_inactive"
+msgstr "Inaktiv"
+
+msgid "cmps_install"
+msgstr "Installieren"
+
+msgid "cmps_is_active"
+msgstr "Aktiviert"
+
+msgid "cmps_is_installed"
+msgstr "Installiert"
+
+msgid "cmps_lang_files"
+msgstr "Sprachdatei"
+
+msgid "cmps_lang_prefix"
+msgstr "Präfix Sprachvariable"
+
+msgid "cmps_languages"
+msgstr "Sprachen"
+
+msgid "cmps_main_dir"
+msgstr "Hauptverzeichnis"
+
+msgid "cmps_missing"
+msgstr "Fehlt"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+msgid "cmps_must_installed"
+msgstr "Die Komponente muss installiert werden."
+
+msgid "cmps_name"
+msgstr "Name"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "Dieses Plugin kann nicht mit der aktuellen Version dieser ILIAS-Installation verwendet werden."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Dieses Plugin kann nur mit einer neueren ILIAS-Version verwendet werden. Bitte aktualisieren Sie Ihre ILIAS-Version."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Diese ILIAS-Version benötigt eine neuere Plugin-Version. Bitte aktualisieren Sie das Plugin."
+
+msgid "cmps_needs_update"
+msgstr "Aktualisierung notwendig"
+
+msgid "cmps_needs_upgrade"
+msgstr "Die aktuell installierte Plugin-Version ist älter als die zuvor installierte. Bitte installieren Sie das aktuelle Plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Kein Datenbank-Update-Skript verfügbar"
+
+msgid "cmps_no_language_file_available"
+msgstr "Keine Sprachdatei verfügbar"
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Das Plugin wurde aktiviert."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Präfixe Datenbanktabelle"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Das Plugin wurde deaktiviert."
+
+msgid "cmps_plugin_deinstalled"
+msgstr "Das Plugin wurde deinstalliert."
+
+msgid "cmps_plugin_file"
+msgstr "Plugin-Datei"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin-Sprachpräfixe"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin-Slot"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Alle Sprach- und Datenbankeinträge, die sich auf das Plugin beziehen, wurden gelöscht und das Plugin deaktiviert. Sie können jetzt das Verzeichnis und den Quellcode des Plugins aus dem ‘Customizing’-Verzeichnis entfernen, um das Plugin endgültig zu deinstallieren."
+
+msgid "cmps_plugin_updated"
+msgstr "Das Plugin wurde aktualisiert."
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Sprachen neu laden"
+
+msgid "cmps_refresh_lng"
+msgstr "Sprache aktualisiert"
+
+msgid "cmps_rep_object"
+msgstr "Objekttyp im Magazin"
+
+msgid "cmps_repository_object_types"
+msgstr "Objekttypen im Magazin"
+
+msgid "cmps_responsible"
+msgstr "Verantwortlich"
+
+msgid "cmps_responsible_mail"
+msgstr "Mail (Verantwortlicher)"
+
+msgid "cmps_save_options"
+msgstr "Speichern"
+
+msgid "cmps_service"
+msgstr "Dienst"
+
+msgid "cmps_show_details"
+msgstr "Details anzeigen"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+msgid "cmps_supports_cli_setup"
+msgstr "Unterstützt CLI-Setup"
+
+msgid "cmps_supports_export"
+msgstr "Unterstützt Export"
+
+msgid "cmps_supports_learning_progress"
+msgstr "Unterstützt Lernfortschritt"
+
+msgid "cmps_uninstall"
+msgstr "Deinstallieren"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Wollen Sie wirklich das Plugin „%s“ deinstallieren und alle zugehörigen Datenbankeinträge löschen?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Das Plugin \"%1$s\" ist aktuell nicht aktivierbar (\"%2$s\"). In diesem Status können Plugins nicht komplett deinstalliert werden. Da nur die Systemintegration entfernt wird, könnten Plugin-eigene Daten zurückbleiben. Möchten Sie mit der teilweisen Deinstallation fortfahren?"
+
+msgid "cmps_update"
+msgstr "Aktualisieren"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_el.po b/components/ILIAS/Component/lang/cmps_el.po
new file mode 100644
index 000000000000..20f17f2cfbb0
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_el.po
@@ -0,0 +1,259 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: el\n"
+"Language-Team: Greek\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activate"
+
+msgid "cmps_active"
+msgstr "Active"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+msgid "cmps_group"
+msgstr "Group"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+msgid "cmps_languages"
+msgstr "Languages"
+
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+msgid "cmps_missing"
+msgstr "Missing"
+
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Update"
+
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_en.po b/components/ILIAS/Component/lang/cmps_en.po
new file mode 100644
index 000000000000..c7bced4c6785
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_en.po
@@ -0,0 +1,231 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: en\n"
+"Language-Team: English\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activate"
+
+msgid "cmps_active"
+msgstr "Active"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Available"
+
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Configure"
+
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Directory"
+
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+msgid "cmps_group"
+msgstr "Group"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+msgid "cmps_install"
+msgstr "Install"
+
+msgid "cmps_is_active"
+msgstr "Activated"
+
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+msgid "cmps_languages"
+msgstr "Languages"
+
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+msgid "cmps_missing"
+msgstr "Missing"
+
+msgid "cmps_module"
+msgstr "Module"
+
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Name"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "All language and database related entries have been deleted and the plugin has been set inactive. Now you can remove the plugin's directory and source code in the ‘Customizing’ directory to uninstall the plugin definitely."
+
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Refresh Languages"
+
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Save"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\" and remove all related database entries?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Update"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "The database is up to date."
+
+msgid "no_changes"
+msgstr "No changes"
diff --git a/components/ILIAS/Component/lang/cmps_es.po b/components/ILIAS/Component/lang/cmps_es.po
new file mode 100644
index 000000000000..58d439e1e9c7
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_es.po
@@ -0,0 +1,232 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: es\n"
+"Language-Team: Spanish\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activar"
+
+msgid "cmps_active"
+msgstr "Activo"
+
+msgid "cmps_add_new_rank"
+msgstr "Posición en la lista de Añadir-Nuevo-Objeto"
+
+msgid "cmps_available"
+msgstr "Disponible"
+
+msgid "cmps_available_version"
+msgstr "Versión disponible"
+
+msgid "cmps_basic_files"
+msgstr "Archivos básicos"
+
+msgid "cmps_class_file"
+msgstr "Archivo de clase"
+
+msgid "cmps_component"
+msgstr "Componente"
+
+msgid "cmps_configure"
+msgstr "Configurar"
+
+msgid "cmps_current_db_version"
+msgstr "Versión actual de BD"
+
+msgid "cmps_current_version"
+msgstr "Versión actual"
+
+msgid "cmps_database"
+msgstr "Base de datos"
+
+msgid "cmps_db_update"
+msgstr "Script de actualización de BD"
+
+msgid "cmps_deactivate"
+msgstr "Desactivar"
+
+msgid "cmps_detailed_information"
+msgstr "Información detallada"
+
+msgid "cmps_dir"
+msgstr "Directorio"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Habilitar creación"
+
+msgid "cmps_file_version"
+msgstr "Actualizar versión de archivo"
+
+msgid "cmps_group"
+msgstr "Grupo"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Versión máx. de ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Versión mín. de ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Inactivo"
+
+msgid "cmps_install"
+msgstr "Instalar"
+
+msgid "cmps_is_active"
+msgstr "Activado"
+
+msgid "cmps_is_installed"
+msgstr "Instalado"
+
+msgid "cmps_lang_files"
+msgstr "Archivos de idioma"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefijo de variables de idioma"
+
+msgid "cmps_languages"
+msgstr "Idiomas"
+
+msgid "cmps_main_dir"
+msgstr "Directorio principal"
+
+msgid "cmps_missing"
+msgstr "Faltante"
+
+msgid "cmps_module"
+msgstr "Módulo"
+
+msgid "cmps_must_installed"
+msgstr "El componente debe estar instalado."
+
+msgid "cmps_name"
+msgstr "Nombre"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "Este complemento no funciona con su versión actual de ILIAS."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Esta versión del complemento solo funciona en versiones más recientes de ILIAS. Por favor, actualice ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Esta versión de ILIAS necesita una versión más reciente del complemento. Por favor, actualice el complemento."
+
+msgid "cmps_needs_update"
+msgstr "Necesita actualización."
+
+msgid "cmps_needs_upgrade"
+msgstr "La versión del complemento instalada actualmente es más antigua que una versión instalada previamente. Por favor, actualice el complemento."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No hay ningún script de actualización de base de datos disponible."
+
+msgid "cmps_no_language_file_available"
+msgstr "No hay ningún archivo de idioma disponible."
+
+msgid "cmps_plugin"
+msgstr "Complemento (Plugin)"
+
+msgid "cmps_plugin_activated"
+msgstr "El complemento ha sido activado."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Prefijos de tablas de BD"
+
+msgid "cmps_plugin_deactivated"
+msgstr "El complemento ha sido desactivado."
+
+msgid "cmps_plugin_deinstalled"
+msgstr "El complemento ha sido desinstalado"
+
+msgid "cmps_plugin_file"
+msgstr "Archivo del complemento"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Prefijos de idioma del complemento"
+
+msgid "cmps_plugin_slot"
+msgstr "Ubicación (Slot) del complemento"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Todas las entradas relacionadas con el idioma y la base de datos han sido eliminadas y el complemento se ha configurado como inactivo. Ahora puede eliminar el directorio y el código fuente del complemento en el directorio 'Customizing' para desinstalar el complemento definitivamente."
+
+msgid "cmps_plugin_updated"
+msgstr "El complemento ha sido actualizado"
+
+msgid "cmps_plugins"
+msgstr "Complementos (Plugins)"
+
+msgid "cmps_refresh"
+msgstr "Actualizar idiomas"
+
+msgid "cmps_refresh_lng"
+msgstr "Actualizar idioma"
+
+msgid "cmps_rep_object"
+msgstr "Tipo de objeto de repositorio"
+
+msgid "cmps_repository_object_types"
+msgstr "Tipos de objetos de repositorio"
+
+msgid "cmps_responsible"
+msgstr "Responsable"
+
+msgid "cmps_responsible_mail"
+msgstr "Correo electrónico (Responsable)"
+
+msgid "cmps_save_options"
+msgstr "Guardar"
+
+msgid "cmps_service"
+msgstr "Servicio"
+
+msgid "cmps_show_details"
+msgstr "Mostrar detalles"
+
+msgid "cmps_slots"
+msgstr "Ubicaciones (Slots)"
+
+msgid "cmps_status"
+msgstr "Estado"
+
+msgid "cmps_supports_cli_setup"
+msgstr "Soporta configuración por CLI"
+
+msgid "cmps_supports_export"
+msgstr "Soporta exportación"
+
+msgid "cmps_supports_learning_progress"
+msgstr "Soporta progreso de aprendizaje"
+
+msgid "cmps_uninstall"
+msgstr "Desinstalar"
+
+msgid "cmps_uninstall_confirm"
+msgstr "¿Está seguro de que desea desinstalar el complemento \"%s\" y eliminar todas las entradas de la base de datos relacionadas?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "El complemento \"%1$s\" actualmente no puede activarse (\"%2$s\"). En este estado, los complementos no pueden desinstalarse completamente. Dado que solo se revierte la integración del núcleo, los datos personalizados del complemento pueden permanecer. ¿Desea continuar con la desinstalación parcial?"
+
+msgid "cmps_update"
+msgstr "Actualizar"
+
+msgid "cmps_version"
+msgstr "Versión"
+
+msgid "database_is_uptodate"
+msgstr "La base de datos está actualizada."
+
+msgid "no_changes"
+msgstr "Sin cambios"
diff --git a/components/ILIAS/Component/lang/cmps_et.po b/components/ILIAS/Component/lang/cmps_et.po
new file mode 100644
index 000000000000..f59bcf6b4695
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_et.po
@@ -0,0 +1,251 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: et\n"
+"Language-Team: Estonian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktiveeri"
+
+msgid "cmps_active"
+msgstr "Aktiivne"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Kättesaadav"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Põhifailid"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Konfigureeri"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Hetkeversioon"
+
+msgid "cmps_database"
+msgstr "Andmebaas"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deaktiveeri"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Kataloog"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Uuenda faili versiooni"
+
+msgid "cmps_group"
+msgstr "Grupp"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Passiivne"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Keelefailid"
+
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+msgid "cmps_languages"
+msgstr "Keeled"
+
+msgid "cmps_main_dir"
+msgstr "Juurkataloog"
+
+msgid "cmps_missing"
+msgstr "Puudub"
+
+msgid "cmps_module"
+msgstr "Moodul"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Nimi"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Antud lisamoodul töötab ainult ILIASe uuema versiooniga. Uuenda ILIASe versiooni."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "ILIASe antud versioon vajab lisamooduli uuemat versiooni. Uuenda lisamoodulit."
+
+msgid "cmps_needs_update"
+msgstr "Vajab uuendamist."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "Keelefaili pole."
+
+msgid "cmps_plugin"
+msgstr "Lisamoodul"
+
+msgid "cmps_plugin_activated"
+msgstr "Lisamoodul on aktiveeritud."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Lisamoodul on deaktiveeritud."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Lisamooduli fail"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Lisamoodulid"
+
+msgid "cmps_refresh"
+msgstr "Värskenda"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Varamu objekti tüüp"
+
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Salvestamise valikud"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Näita detaile"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Staatus"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Uuenda"
+
+msgid "cmps_version"
+msgstr "Versioon"
+
+msgid "database_is_uptodate"
+msgstr "The database is up to date."
+
+msgid "no_changes"
+msgstr "Muudatused puuduvad"
diff --git a/components/ILIAS/Component/lang/cmps_fa.po b/components/ILIAS/Component/lang/cmps_fa.po
new file mode 100644
index 000000000000..8f5c9a0c6794
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_fa.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: fa\n"
+"Language-Team: Persian (Farsi)\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n > 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activate"
+
+msgid "cmps_active"
+msgstr "Active"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "بروز رسانی نسخه فایل"
+
+msgid "cmps_group"
+msgstr "Group"
+
+msgid "cmps_id"
+msgstr "شناسه"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+msgid "cmps_languages"
+msgstr "زبانها"
+
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+msgid "cmps_missing"
+msgstr "Missing"
+
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "بروز رسانی"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "The database is up to date."
+
+msgid "no_changes"
+msgstr "No changes"
diff --git a/components/ILIAS/Component/lang/cmps_fr.po b/components/ILIAS/Component/lang/cmps_fr.po
new file mode 100644
index 000000000000..44a9c3ed8512
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_fr.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: fr\n"
+"Language-Team: French\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activer"
+
+msgid "cmps_active"
+msgstr "Actif"
+
+msgid "cmps_add_new_rank"
+msgstr "Position dans la liste Ajouter-un-nouvel-objet"
+
+msgid "cmps_available"
+msgstr "Disponible"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Fichiers basiques"
+
+msgid "cmps_class_file"
+msgstr "Fichier de Classe"
+
+msgid "cmps_component"
+msgstr "Composant"
+
+msgid "cmps_configure"
+msgstr "Configurer"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Version actuelle"
+
+msgid "cmps_database"
+msgstr "Base de données"
+
+msgid "cmps_db_update"
+msgstr "Script de mise à jour de la BbD"
+
+msgid "cmps_deactivate"
+msgstr "Désactiver"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Dossier"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Autoriser Création"
+
+msgid "cmps_file_version"
+msgstr "Mettre à jour la version du fichier"
+
+msgid "cmps_group"
+msgstr "Groupe"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Version Max. ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Version Min. ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Inactif"
+
+msgid "cmps_install"
+msgstr "Installer"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Fichier de langues"
+
+msgid "cmps_lang_prefix"
+msgstr "Préfixe des variables de langue"
+
+msgid "cmps_languages"
+msgstr "Langues"
+
+msgid "cmps_main_dir"
+msgstr "Dossier Principal"
+
+msgid "cmps_missing"
+msgstr "Manquant"
+
+msgid "cmps_module"
+msgstr "Module"
+
+msgid "cmps_must_installed"
+msgstr "Le composant doit être installé."
+
+msgid "cmps_name"
+msgstr "Nom"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Cette version de plugin fonctionne uniquement sur les nouvelles versions d'ILIAS. Veuillez mettre à jour ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Cette version d'ILIAS nécessite une nouvelle version de plugin. Veuillez mettre à jour le plugin."
+
+msgid "cmps_needs_update"
+msgstr "Nécessite un update."
+
+msgid "cmps_needs_upgrade"
+msgstr "La version de plugin actuellement installée est plus ancienne que la précédente installée. Veuillez mettre à jour le plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Pas de script de mise à jour de la BdD disponible."
+
+msgid "cmps_no_language_file_available"
+msgstr "Pas de fichier de langue disponible."
+
+msgid "cmps_plugin"
+msgstr "Plug-in"
+
+msgid "cmps_plugin_activated"
+msgstr "Le plugin a été activé."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Préfixes de tables de BdD"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Le plugin a été désactivé."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Fichier Plugin"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Préfixes de langage de plugin"
+
+msgid "cmps_plugin_slot"
+msgstr "Slot du Plugin"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Le plugin a été désinstallé."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plug-ins"
+
+msgid "cmps_refresh"
+msgstr "Rafraà®chir"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Type d'Objet"
+
+msgid "cmps_repository_object_types"
+msgstr "Types d'Objets"
+
+msgid "cmps_responsible"
+msgstr "Responsable"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Enregistrer Options"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Afficher détails"
+
+msgid "cmps_slots"
+msgstr "Emplacements"
+
+msgid "cmps_status"
+msgstr "Statut"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Désinstaller"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Etes-vous sûr de vouloir désinstaller le plugin \"%s\"?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Le plug-in «%1$s» ne peut pas être activé pour le moment («%2$s»). Dans cet état, les plug-ins ne peuvent pas être installés complètement. Puisque seule l’intégration principale est inversée, les données personnalisées des plug-ins peuvent rester. Voulez-vous continuer la désinstallation partielle?"
+
+msgid "cmps_update"
+msgstr "Mettre à jour"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "La base de donnée est à jour."
+
+msgid "no_changes"
+msgstr "Aucun changement"
diff --git a/components/ILIAS/Component/lang/cmps_hr.po b/components/ILIAS/Component/lang/cmps_hr.po
new file mode 100644
index 000000000000..643c41b9b6a1
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_hr.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: hr\n"
+"Language-Team: Croatian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktiviraj"
+
+msgid "cmps_active"
+msgstr "Aktivan"
+
+msgid "cmps_add_new_rank"
+msgstr "Pozicija na popisu dodavanja novih objekata"
+
+msgid "cmps_available"
+msgstr "Dostupno"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Osnovne datoteke"
+
+msgid "cmps_class_file"
+msgstr "Datoteka razreda"
+
+msgid "cmps_component"
+msgstr "Komponenta"
+
+msgid "cmps_configure"
+msgstr "Konfiguriraj"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Trenutačna verzija"
+
+msgid "cmps_database"
+msgstr "Baza podataka"
+
+msgid "cmps_db_update"
+msgstr "DB Skripta za ažuriranje"
+
+msgid "cmps_deactivate"
+msgstr "Deaktiviraj"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Direktorij"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Omogući kreiranje"
+
+msgid "cmps_file_version"
+msgstr "Ažuriranje verzije datoteke"
+
+msgid "cmps_group"
+msgstr "Skupina"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maks. verzija ILIAS-a"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. verzija ILIAS-a"
+
+msgid "cmps_inactive"
+msgstr "Neaktivan"
+
+msgid "cmps_install"
+msgstr "Instaliraj"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Jezične datoteke"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefiks jezičnih varijabli"
+
+msgid "cmps_languages"
+msgstr "Jezici"
+
+msgid "cmps_main_dir"
+msgstr "Glavni direktorij"
+
+msgid "cmps_missing"
+msgstr "Nedostaje"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+msgid "cmps_must_installed"
+msgstr "Komponenta se mora instalirati."
+
+msgid "cmps_name"
+msgstr "Ime"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Ova verzija dodatka radi samo na novijim verzijama ILIAS-a. Molimo nadogradite ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Potrebno je ažuriranje."
+
+msgid "cmps_needs_upgrade"
+msgstr "Trenutno instalirana verzija dodatka starija je od prethodno instalirane verzije. Molimo nadogradite dodatak."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Nije dostupna skripta za ažuriranje baze podataka"
+
+msgid "cmps_no_language_file_available"
+msgstr "Nije dostupna jezična datoteka."
+
+msgid "cmps_plugin"
+msgstr "Dodatak"
+
+msgid "cmps_plugin_activated"
+msgstr "Dodatak je aktiviran."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Prefiksi tablice baze podataka"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Dodatak je deaktiviran."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Datoteka dodatka"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Dodatak jezičnih prefiksa"
+
+msgid "cmps_plugin_slot"
+msgstr "Slot dodatka"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Svi unosi povezani s jezikom i bazom podataka izbrisani su, a dodatak je neaktivan. Sada možete ukloniti direktorij i izvorni kod dodatka u direktoriju ‘Prilagođavanje’ kako biste dodatak definitivno deinstalirali."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Dodaci"
+
+msgid "cmps_refresh"
+msgstr "Osvježi jezike"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Tip objekta u repozitoriju"
+
+msgid "cmps_repository_object_types"
+msgstr "Tipovi objekata u repozitoriju"
+
+msgid "cmps_responsible"
+msgstr "Odgovoran"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Spremi"
+
+msgid "cmps_service"
+msgstr "Servis"
+
+msgid "cmps_show_details"
+msgstr "Pokaži detalje"
+
+msgid "cmps_slots"
+msgstr "Slotovi"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Deinstaliraj"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Jeste li sigurni da želite deinstalirati dodatak \"%s\" i ukloniti sve povezane unose u bazu podataka?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Dodatak \"%1$s\" trenutačno se ne može aktivirati (\"%2$s\"). U ovom stanju dodaci se ne mogu potpuno deinstalirati. Kako se briše samo integracija u sustav, vlastite datoteke dodataka mogu ostati. Želite li nastaviti s djelomičnom deinstalacijom?"
+
+msgid "cmps_update"
+msgstr "Ažuriraj"
+
+msgid "cmps_version"
+msgstr "Verzija"
+
+msgid "database_is_uptodate"
+msgstr "Baza podataka je ažurirana."
+
+msgid "no_changes"
+msgstr "Nema promjena"
diff --git a/components/ILIAS/Component/lang/cmps_hu.po b/components/ILIAS/Component/lang/cmps_hu.po
new file mode 100644
index 000000000000..1d69b181880c
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_hu.po
@@ -0,0 +1,232 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: hu\n"
+"Language-Team: Hungarian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktiválás"
+
+msgid "cmps_active"
+msgstr "Aktív"
+
+msgid "cmps_add_new_rank"
+msgstr "Pozíció az Új objektum létrehozása listában"
+
+msgid "cmps_available"
+msgstr "Elérhető"
+
+msgid "cmps_available_version"
+msgstr "Elérhető verzió"
+
+msgid "cmps_basic_files"
+msgstr "Alapfájlok"
+
+msgid "cmps_class_file"
+msgstr "Osztályfájl"
+
+msgid "cmps_component"
+msgstr "Komponens"
+
+msgid "cmps_configure"
+msgstr "Konfigurálás"
+
+msgid "cmps_current_db_version"
+msgstr "Jelenlegi adatbázis-verzió"
+
+msgid "cmps_current_version"
+msgstr "Jelenlegi verzió"
+
+msgid "cmps_database"
+msgstr "Adatbázis"
+
+msgid "cmps_db_update"
+msgstr "Adatbázis-frissítési szkript"
+
+msgid "cmps_deactivate"
+msgstr "Deaktiválás"
+
+msgid "cmps_detailed_information"
+msgstr "Részletes információ"
+
+msgid "cmps_dir"
+msgstr "Mappa"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Létrehozás engedélyezése"
+
+msgid "cmps_file_version"
+msgstr "Frissítési fájlverzió"
+
+msgid "cmps_group"
+msgstr "Csoport"
+
+msgid "cmps_id"
+msgstr "Azonosító"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maximum ILIAS-verzió"
+
+msgid "cmps_ilias_min_version"
+msgstr "Minimum ILIAS-verzió"
+
+msgid "cmps_inactive"
+msgstr "Inaktív"
+
+msgid "cmps_install"
+msgstr "Telepítés"
+
+msgid "cmps_is_active"
+msgstr "Aktivált"
+
+msgid "cmps_is_installed"
+msgstr "Telepített"
+
+msgid "cmps_lang_files"
+msgstr "Nyelvi fájlok"
+
+msgid "cmps_lang_prefix"
+msgstr "Nyelvi változó előtag"
+
+msgid "cmps_languages"
+msgstr "Nyelvek"
+
+msgid "cmps_main_dir"
+msgstr "Főmappa"
+
+msgid "cmps_missing"
+msgstr "Hiányzik"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+msgid "cmps_must_installed"
+msgstr "A komponenst telepíteni kell."
+
+msgid "cmps_name"
+msgstr "Név"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "Ez a bővítmény nem működik ezen az ILIAS verzión."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Ez a bővítményverzió csak újabb ILIAS-verziókon fut. Frissítse az ILIAS-t!"
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Ehhez az ILIAS-verzióhoz újabb bővítményverzió szükséges. Frissítse a bővítményt!"
+
+msgid "cmps_needs_update"
+msgstr "Frissítés szükséges."
+
+msgid "cmps_needs_upgrade"
+msgstr "A jelenleg telepített bővítmény verziója régebbi, mint egy korábban telepítetté. Frissítse a bővítményt!"
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Nem érhető el az adatbázis-frissítő szkript."
+
+msgid "cmps_no_language_file_available"
+msgstr "Nem érhető el a nyelvi fájl."
+
+msgid "cmps_plugin"
+msgstr "Bővítmény"
+
+msgid "cmps_plugin_activated"
+msgstr "A bővítményt bekapcsolta."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Adatbázistábla előtag"
+
+msgid "cmps_plugin_deactivated"
+msgstr "A bővítményt kikapcsolta."
+
+msgid "cmps_plugin_deinstalled"
+msgstr "A bővítményt eltávolította."
+
+msgid "cmps_plugin_file"
+msgstr "Bővítmény fájl"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Bővítmény nyelvi előtagok"
+
+msgid "cmps_plugin_slot"
+msgstr "Bővítmény csatlakozás"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Az összes nyelvi- és adatbázis-bejegyzést sikeresen eltávolította, és a bővítményt inaktiválta. Most már biztonságosan letávolíthatja a bővítmény - ‘Customizing’ mappa alatt található - fájljait/mappáit."
+
+msgid "cmps_plugin_updated"
+msgstr "A bővítményt frissítette"
+
+msgid "cmps_plugins"
+msgstr "Bővítmények"
+
+msgid "cmps_refresh"
+msgstr "Frissítés"
+
+msgid "cmps_refresh_lng"
+msgstr "Nyelv frissítés"
+
+msgid "cmps_rep_object"
+msgstr "Tartalomtárban lévő objektumtípus"
+
+msgid "cmps_repository_object_types"
+msgstr "ILIAS-objektum típusok"
+
+msgid "cmps_responsible"
+msgstr "Felelős"
+
+msgid "cmps_responsible_mail"
+msgstr "E-mail cím (Felelős)"
+
+msgid "cmps_save_options"
+msgstr "Mentés"
+
+msgid "cmps_service"
+msgstr "Szolgáltatás"
+
+msgid "cmps_show_details"
+msgstr "Részletek megjelenítése"
+
+msgid "cmps_slots"
+msgstr "Foglalatok"
+
+msgid "cmps_status"
+msgstr "Állapot"
+
+msgid "cmps_supports_cli_setup"
+msgstr "Parancssori telepítés támogatása"
+
+msgid "cmps_supports_export"
+msgstr "Exportálás támogatása"
+
+msgid "cmps_supports_learning_progress"
+msgstr "Tanulási haladás támogatása"
+
+msgid "cmps_uninstall"
+msgstr "Eltávolítás"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Biztos, hogy eltávolítja a következő ‘%s’ bővítményt annak összes adatbázis-bejegyzésével együtt?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "‘%1$s’ bővítmény jelenleg nem aktiválható (‘%2$s’), így nem távolítható el teljesen, néhány adata a rendszerben marad. Biztos, hogy folytatja a részleges eltávolítást?"
+
+msgid "cmps_update"
+msgstr "Frissítés"
+
+msgid "cmps_version"
+msgstr "Verzió"
+
+msgid "database_is_uptodate"
+msgstr "Az adatbázis naprakész."
+
+msgid "no_changes"
+msgstr "Nincsenek változások"
diff --git a/components/ILIAS/Component/lang/cmps_it.po b/components/ILIAS/Component/lang/cmps_it.po
new file mode 100644
index 000000000000..575d6137c231
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_it.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: it\n"
+"Language-Team: Italian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Attivare"
+
+msgid "cmps_active"
+msgstr "Attivo"
+
+msgid "cmps_add_new_rank"
+msgstr "Posizione nel menù per Aggiungi-Nuovo-Oggetto"
+
+msgid "cmps_available"
+msgstr "Disponibile"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "File di base"
+
+msgid "cmps_class_file"
+msgstr "File classe"
+
+msgid "cmps_component"
+msgstr "Componenti"
+
+msgid "cmps_configure"
+msgstr "Configura"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Versione attuale"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "Script per l'attualizzazione della banca dati"
+
+msgid "cmps_deactivate"
+msgstr "Disattivato"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Consenti creazione"
+
+msgid "cmps_file_version"
+msgstr "Aggiornamento del file"
+
+msgid "cmps_group"
+msgstr "Gruppo"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Versione max. di ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Versione min. di ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Inattivo"
+
+msgid "cmps_install"
+msgstr "Installa"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "File di lingua"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefisso della variabile di testo"
+
+msgid "cmps_languages"
+msgstr "Lingue"
+
+msgid "cmps_main_dir"
+msgstr "Directory principale"
+
+msgid "cmps_missing"
+msgstr "Mancante"
+
+msgid "cmps_module"
+msgstr "Modulo"
+
+msgid "cmps_must_installed"
+msgstr "Il componente deve essere installato."
+
+msgid "cmps_name"
+msgstr "Nome"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Questa versione del plugin funziona solo su una versione recente di ILIAS. Aggiornalo per favore."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Questa versione di ILIAS necessita di un'aggiornamento del plugin. Procedi per favore."
+
+msgid "cmps_needs_update"
+msgstr "Aggiornamento necessario"
+
+msgid "cmps_needs_upgrade"
+msgstr "Il plugin che hai installato è di una versione precedente. Scarica la versione recente."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Non è disponibile nessuno script per il DB"
+
+msgid "cmps_no_language_file_available"
+msgstr "Nessun file di lingua disponibile"
+
+msgid "cmps_plugin"
+msgstr "Plug-in"
+
+msgid "cmps_plugin_activated"
+msgstr "Il plugin è stato attivato."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Prefissi delle tabelle del DB"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Il plugin è stato disattivato."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "File Plugin"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin dei prefissi di lingua"
+
+msgid "cmps_plugin_slot"
+msgstr "Plug-in fascia oraria"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Il plugin è stato disinstallato."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plug-in"
+
+msgid "cmps_refresh"
+msgstr "Aggiorna"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Oggetto del repository"
+
+msgid "cmps_repository_object_types"
+msgstr "Tipi di oggetto repository"
+
+msgid "cmps_responsible"
+msgstr "Responsabile"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Salva le opzioni"
+
+msgid "cmps_service"
+msgstr "Servizio"
+
+msgid "cmps_show_details"
+msgstr "Mostra i dettagli"
+
+msgid "cmps_slots"
+msgstr "Fasce orarie"
+
+msgid "cmps_status"
+msgstr "Stato"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Disinstalla"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Sei sicuro di voler disinstallare il plugin \"%s\"?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Il plug-in \"%1$s\" attualmente non può essere attivato (\"%2$s\"). In questo stato i plug-in non possono essere disinstallati completamente. Poiché solo l'integrazione di base viene ripristinata, i dati personalizzati del plug-in possono rimanere. Vuoi continuare con la disinstallazione parziale?"
+
+msgid "cmps_update"
+msgstr "Aggiornamento"
+
+msgid "cmps_version"
+msgstr "Versione"
+
+msgid "database_is_uptodate"
+msgstr "Il database è aggiornato."
+
+msgid "no_changes"
+msgstr "Nessuna modifica"
diff --git a/components/ILIAS/Component/lang/cmps_ja.po b/components/ILIAS/Component/lang/cmps_ja.po
new file mode 100644
index 000000000000..677a60918580
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_ja.po
@@ -0,0 +1,232 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ja\n"
+"Language-Team: Japanese\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "有効にする"
+
+msgid "cmps_active"
+msgstr "アクティブ"
+
+msgid "cmps_add_new_rank"
+msgstr "新規追加オブジェクトラインの位置"
+
+msgid "cmps_available"
+msgstr "利用可能"
+
+msgid "cmps_available_version"
+msgstr "利用可能なバージョン"
+
+msgid "cmps_basic_files"
+msgstr "基本ファイル"
+
+msgid "cmps_class_file"
+msgstr "クラスファイル"
+
+msgid "cmps_component"
+msgstr "構成要素"
+
+msgid "cmps_configure"
+msgstr "構成"
+
+msgid "cmps_current_db_version"
+msgstr "現在のDB-Version"
+
+msgid "cmps_current_version"
+msgstr "現在のバージョン"
+
+msgid "cmps_database"
+msgstr "データベース"
+
+msgid "cmps_db_update"
+msgstr "DB更新スクリプト"
+
+msgid "cmps_deactivate"
+msgstr "アクティブ解除"
+
+msgid "cmps_detailed_information"
+msgstr "詳細を表示情報"
+
+msgid "cmps_dir"
+msgstr "ディレクトリ"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "作成を有効にする"
+
+msgid "cmps_file_version"
+msgstr "更新ファイルバージョン"
+
+msgid "cmps_group"
+msgstr "グループ"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "非アクティブ"
+
+msgid "cmps_install"
+msgstr "インストール"
+
+msgid "cmps_is_active"
+msgstr "アクティブ化"
+
+msgid "cmps_is_installed"
+msgstr "インストール済"
+
+msgid "cmps_lang_files"
+msgstr "言語ファイル"
+
+msgid "cmps_lang_prefix"
+msgstr "言語変数プレフィックス"
+
+msgid "cmps_languages"
+msgstr "言語"
+
+msgid "cmps_main_dir"
+msgstr "メインディレクトリ"
+
+msgid "cmps_missing"
+msgstr "不明"
+
+msgid "cmps_module"
+msgstr "モジュール"
+
+msgid "cmps_must_installed"
+msgstr "コンポーネントをインストールする必要があります。"
+
+msgid "cmps_name"
+msgstr "名前"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "このプラグインは現在のILIASバージョンでは動作しません。"
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "このプラグインバージョンは新しいILIASバージョンでのみ動作します。 ILIASをバージョンアップしてください。"
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "このILIASのバージョンには新しいプラグインバージョンが必要です。プラグインを更新してください。"
+
+msgid "cmps_needs_update"
+msgstr "アップグレードが必要"
+
+msgid "cmps_needs_upgrade"
+msgstr "現在インストールされているプラグインバージョンは以前インストールしていたバージョンよりも古いものです。プラグインをアップグレードしてください。"
+
+msgid "cmps_no_db_update_file_available"
+msgstr "DB更新スクリプトがありません。"
+
+msgid "cmps_no_language_file_available"
+msgstr "言語ファイルがありません。"
+
+msgid "cmps_plugin"
+msgstr "プラグイン"
+
+msgid "cmps_plugin_activated"
+msgstr "プラグインがアクティグになりました。"
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DBテーブルプレフィックス"
+
+msgid "cmps_plugin_deactivated"
+msgstr "プラグインは解除されました。"
+
+msgid "cmps_plugin_deinstalled"
+msgstr "このプラグインは削除されました。"
+
+msgid "cmps_plugin_file"
+msgstr "プラグインファイル"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "プラグイン言語プレフィックス"
+
+msgid "cmps_plugin_slot"
+msgstr "プラグインスロット"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "全ての言語とデータベース関連エントリーは削除されてプラグインは設定解除されました。完全にプラグインをアンインストールするために'Customizing'ディレクトリ内のプラグインのディレクトリとソースコードを削除できます。"
+
+msgid "cmps_plugin_updated"
+msgstr "プラグインはupdateされました。"
+
+msgid "cmps_plugins"
+msgstr "プラグイン"
+
+msgid "cmps_refresh"
+msgstr "言語をリフレッシュ"
+
+msgid "cmps_refresh_lng"
+msgstr "言語をリフレッシュ"
+
+msgid "cmps_rep_object"
+msgstr "オブジェクトタイプのリポジトリ"
+
+msgid "cmps_repository_object_types"
+msgstr "リポジトリオブジェクトタイプ"
+
+msgid "cmps_responsible"
+msgstr "責任者"
+
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "オプションを保存"
+
+msgid "cmps_service"
+msgstr "サービス"
+
+msgid "cmps_show_details"
+msgstr "詳細を表示"
+
+msgid "cmps_slots"
+msgstr "スロット"
+
+msgid "cmps_status"
+msgstr "ステータス"
+
+msgid "cmps_supports_cli_setup"
+msgstr "CLI-Setupをサポート"
+
+msgid "cmps_supports_export"
+msgstr "exportをサポート"
+
+msgid "cmps_supports_learning_progress"
+msgstr "学習進捗をサポート"
+
+msgid "cmps_uninstall"
+msgstr "アンインストール"
+
+msgid "cmps_uninstall_confirm"
+msgstr "本当にプラグイン\"%s\"をアンインストールして関連の全てのデータベースエントリーを削除しますか?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "プラグイン\"%1$s\"は現在アクティブ (\"%2$s\")にできません。この状態ではプラグインを完全にアンインストールできません。コアのみの統合に戻りプラグインカスタムデータは残る可能性があります。部分アンインストールを続行しますか?"
+
+msgid "cmps_update"
+msgstr "更新"
+
+msgid "cmps_version"
+msgstr "バージョン"
+
+msgid "database_is_uptodate"
+msgstr "データベースは最新です。"
+
+msgid "no_changes"
+msgstr "変更なし"
diff --git a/components/ILIAS/Component/lang/cmps_ka.po b/components/ILIAS/Component/lang/cmps_ka.po
new file mode 100644
index 000000000000..73086c3ef1ef
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_ka.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ka\n"
+"Language-Team: Georgian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "გააქტიურება"
+
+msgid "cmps_active"
+msgstr "აქტიური"
+
+msgid "cmps_add_new_rank"
+msgstr "ადგილი ახალი ობოექტის დამატების სიაში"
+
+msgid "cmps_available"
+msgstr "შესაძლებელია"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "ძირითადი ფაილები"
+
+msgid "cmps_class_file"
+msgstr "კლასის ფაილი"
+
+msgid "cmps_component"
+msgstr "კომპონენტი"
+
+msgid "cmps_configure"
+msgstr "კონფიგურაცია"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "მიმდინარე ვერსია"
+
+msgid "cmps_database"
+msgstr "მონაცემთა ბაზა"
+
+msgid "cmps_db_update"
+msgstr "დი ბი განახლების ხელნაწერი"
+
+msgid "cmps_deactivate"
+msgstr "დეაქტივაცია"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "მიმართულება"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "ფაილის ვერსიის განახლება"
+
+msgid "cmps_group"
+msgstr "ჯგუფი"
+
+msgid "cmps_id"
+msgstr "აი დი"
+
+msgid "cmps_ilias_max_version"
+msgstr "მაქსიმალური . ილიასის ვერსია"
+
+msgid "cmps_ilias_min_version"
+msgstr "მინ. ილიასის ვერსია"
+
+msgid "cmps_inactive"
+msgstr "არააქტიური"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "ენის ფაილები"
+
+msgid "cmps_lang_prefix"
+msgstr "ენის ცვლადების თავსართი"
+
+msgid "cmps_languages"
+msgstr "ენები"
+
+msgid "cmps_main_dir"
+msgstr "მთავარი მიმართულება"
+
+msgid "cmps_missing"
+msgstr "დაკარგულია"
+
+msgid "cmps_module"
+msgstr "მოდული"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "სახელი"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "ეს პროგრამული გაფართოვების ვერსია მოქმედებს მხოლოდ უახლეს ილიასის ვერსიაზე. გთხოვთ განაახლოთ ილიასი"
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "ილიასის ამ ვერსიას სჭირდება უფრო ახალი პროგრამული გაფართოვების ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების"
+
+msgid "cmps_needs_update"
+msgstr "საჭიროა განახლება"
+
+msgid "cmps_needs_upgrade"
+msgstr "პროგრამული გაფართოების ვერსია რომელიც ახლა დააინსტალირეთ უფრო ძველია ვიდრე წინა პროგრამული გაფართოვებისს ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების"
+
+msgid "cmps_no_db_update_file_available"
+msgstr "დი ბის განახლება შეუძლებელია"
+
+msgid "cmps_no_language_file_available"
+msgstr "ენის ფაილები არ არის ხელმისსაწვდომი"
+
+msgid "cmps_plugin"
+msgstr "პროგრამული გაფართოვება"
+
+msgid "cmps_plugin_activated"
+msgstr "პროგრამული გაფართოვება გააქტიურებულია"
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "დი ბის მაგიდის თავსართები"
+
+msgid "cmps_plugin_deactivated"
+msgstr "პროგრამული გაფართოვება არააქტიურია"
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "პროგრამული გაფართოვების ფაილი"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "ენის თავსართების პროგრამული გაფართოვება"
+
+msgid "cmps_plugin_slot"
+msgstr "პროგრამული გაფართოვების კვალი"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "ჩანართები"
+
+msgid "cmps_refresh"
+msgstr "განახლება"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "სასაწყობე ობიექტების სახეობა"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "პასუხისმგებელი"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "პარამეტრების შენახვა"
+
+msgid "cmps_service"
+msgstr "სერვისი"
+
+msgid "cmps_show_details"
+msgstr "დეტალების ჩვენება"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "სტატუსი"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "განახლება"
+
+msgid "cmps_version"
+msgstr "ვერსია"
+
+msgid "database_is_uptodate"
+msgstr "მონაცემთა ბაზა განახლებულია"
+
+msgid "no_changes"
+msgstr "ცვლილებები არ არის"
diff --git a/components/ILIAS/Component/lang/cmps_lt.po b/components/ILIAS/Component/lang/cmps_lt.po
new file mode 100644
index 000000000000..6078f001d543
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_lt.po
@@ -0,0 +1,261 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: lt\n"
+"Language-Team: Lithuanian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktyvuoti"
+
+msgid "cmps_active"
+msgstr "Aktyvu"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Pasiekiama"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Pagrindiniai Failai"
+
+msgid "cmps_class_file"
+msgstr "Klasės Failas"
+
+msgid "cmps_component"
+msgstr "Komponentas"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Versija"
+
+msgid "cmps_database"
+msgstr "Duombazė"
+
+msgid "cmps_db_update"
+msgstr "DB Atnaujinimo Skriptas"
+
+msgid "cmps_deactivate"
+msgstr "Išjungti"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Katalogas"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Atnaujinti Failo Versiją"
+
+msgid "cmps_group"
+msgstr "Grupė"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maks. ILIAS Versija"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Versija"
+
+msgid "cmps_inactive"
+msgstr "Neaktyvu"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Kalbos Failai"
+
+msgid "cmps_lang_prefix"
+msgstr "Kalbos Kintamųjų Priešdėliai"
+
+msgid "cmps_languages"
+msgstr "Kalbos"
+
+msgid "cmps_main_dir"
+msgstr "Pagrindinis Katalogas"
+
+msgid "cmps_missing"
+msgstr "Trūksta"
+
+msgid "cmps_module"
+msgstr "Modulis"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Pavadinimas"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Šis įskiepis veikia tik naujesnėje ILIAS versijoje. Atnaujinkite ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Šiai ILIAS versijai reikia naujesnio įskiepio. Atnaujinkite įskiepį."
+
+msgid "cmps_needs_update"
+msgstr "Reikia atnaujinti"
+
+msgid "cmps_needs_upgrade"
+msgstr "Dabar diegiamas įskiepis yra senesnis už jau įdiegtą. Atnaujinkite įskiepį."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Nėra DB atnaujinimo skripto"
+
+msgid "cmps_no_language_file_available"
+msgstr "Nėra kalbos failo"
+
+msgid "cmps_plugin"
+msgstr "Įskiepis"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Lentelės Priešdėliai"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Įskiepio Failas"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Įskiepių Kalbos Priešdėliai"
+
+msgid "cmps_plugin_slot"
+msgstr "Įskiepio Vieta"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Įskiepiai"
+
+msgid "cmps_refresh"
+msgstr "Atnaujinti"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Saugyklos Objekto Tipas"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Išsaugoti Nustatymus"
+
+msgid "cmps_service"
+msgstr "Tarnyba"
+
+msgid "cmps_show_details"
+msgstr "Rodyti Detales"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Statusas"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Naujinti"
+
+msgid "cmps_version"
+msgstr "Versija"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_nl.po b/components/ILIAS/Component/lang/cmps_nl.po
new file mode 100644
index 000000000000..c4ed07f8c72a
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_nl.po
@@ -0,0 +1,251 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: nl\n"
+"Language-Team: Dutch\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Inschakelen"
+
+msgid "cmps_active"
+msgstr "Actief"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Beschikbaar"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Standaard bestanden"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Configureren"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Huidige versie"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Uitschakelen"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Folder"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Aanmaken toestaan"
+
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+msgid "cmps_group"
+msgstr "Groep"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Versie"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Versie"
+
+msgid "cmps_inactive"
+msgstr "Inactief"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Taalbestanden"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefix taalvariabelen"
+
+msgid "cmps_languages"
+msgstr "Talen"
+
+msgid "cmps_main_dir"
+msgstr "Hoofdfolder"
+
+msgid "cmps_missing"
+msgstr "Niet aanwezig"
+
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Naam"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Wijziging nodig."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Verversen"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Studiecentrum Object Type"
+
+msgid "cmps_repository_object_types"
+msgstr "Studiecentrum objecttypes"
+
+msgid "cmps_responsible"
+msgstr "Verantwoordelijk"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Opties opslaan"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Tonen Details"
+
+msgid "cmps_slots"
+msgstr "Openingen"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Update"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "De database is bijgewerkt"
+
+msgid "no_changes"
+msgstr "Geen veranderingen"
diff --git a/components/ILIAS/Component/lang/cmps_pl.po b/components/ILIAS/Component/lang/cmps_pl.po
new file mode 100644
index 000000000000..9001042e3898
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_pl.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: pl\n"
+"Language-Team: Polish\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktywuj"
+
+msgid "cmps_active"
+msgstr "Aktywny"
+
+msgid "cmps_add_new_rank"
+msgstr "Jesteś w liście wyboru dla obiektów, które należy dodać"
+
+msgid "cmps_available"
+msgstr "Dostępne"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Pliki podstawowe"
+
+msgid "cmps_class_file"
+msgstr "Pliki klas"
+
+msgid "cmps_component"
+msgstr "Elementy"
+
+msgid "cmps_configure"
+msgstr "Konfiguruj"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Aktualna wersja"
+
+msgid "cmps_database"
+msgstr "Baza danych"
+
+msgid "cmps_db_update"
+msgstr "Skrypt do aktualizacji bazy danych"
+
+msgid "cmps_deactivate"
+msgstr "Deaktywuj"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Indeks"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Włącz tworzenie"
+
+msgid "cmps_file_version"
+msgstr "Zaktualizuj wersję pliku"
+
+msgid "cmps_group"
+msgstr "Grupa"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maksymalna wersja ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Minimalna wersja ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Nieaktywny"
+
+msgid "cmps_install"
+msgstr "Instaluj"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Plik językowy"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefiks dla zmiennej języków"
+
+msgid "cmps_languages"
+msgstr "Języki"
+
+msgid "cmps_main_dir"
+msgstr "Indeks główny"
+
+msgid "cmps_missing"
+msgstr "Brak"
+
+msgid "cmps_module"
+msgstr "Moduł"
+
+msgid "cmps_must_installed"
+msgstr "Należy zainstalować element."
+
+msgid "cmps_name"
+msgstr "Nazwa"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Tej wtyczki można używać wyłącznie z nowszą wersją ILIAS. Zaktualizuj swoją wersję ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Ta wersja ILIAS potrzebują nowszą wersję wtyczki. Zaktualizuj wtyczkę."
+
+msgid "cmps_needs_update"
+msgstr "Konieczna jest aktualizacja"
+
+msgid "cmps_needs_upgrade"
+msgstr "Aktualnie zainstalowana wtyczka jest starsza niż poprzednia wersja. Zainstaluj aktualną wersję wtyczki."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Skrypt dla aktualizacji bazy danych nie jest dostępny"
+
+msgid "cmps_no_language_file_available"
+msgstr "Brak dostępnego pliku językowego"
+
+msgid "cmps_plugin"
+msgstr "Wtyczka"
+
+msgid "cmps_plugin_activated"
+msgstr "Włączono wtyczkę."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Prefiksy dla tabeli bazy danych"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Wyłączono wtyczkę."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plik wtyczki"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Prefiksy językowe wtyczki"
+
+msgid "cmps_plugin_slot"
+msgstr "Interfejs wtyczki"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Wszystkie wpisy językowe i w bazie danych, które odnoszą się do wtyczki, zostały usunięte, a wtyczka odinstalowana. Teraz możesz usunąć indeks i kod źródłowy wtyczki z indeksu ‘Customizing’, aby ostatecznie odinstalować wtyczkę."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Wtyczki"
+
+msgid "cmps_refresh"
+msgstr "Ponownie załaduj język"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Typ obiektu w dostępnych zasobach"
+
+msgid "cmps_repository_object_types"
+msgstr "Typy obiektów w dostępnych zasobach"
+
+msgid "cmps_responsible"
+msgstr "Odpowiedzialny"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Zapisz"
+
+msgid "cmps_service"
+msgstr "Serwis"
+
+msgid "cmps_show_details"
+msgstr "Pokaż szczegóły"
+
+msgid "cmps_slots"
+msgstr "Sloty"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Odinstaluj"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Czy naprawdę chcesz odinstalować wtyczkę \"%s\" i usunąć wszystkie przydzielone wpisy w bazie danych?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Aktualnie nie można aktywować wtyczki \"%1$s\" (\"%2$s\"). Ten status nie pozwala na całkowite odinstalowanie wtyczek. Ponieważ usuwana jest wyłącznie integracja systemowa, istnieje możliwość, że zostaną zachowane niektóre dane wtyczek. Czy chcesz kontynuować częściowe odinstalowanie?"
+
+msgid "cmps_update"
+msgstr "Zaktualizuj"
+
+msgid "cmps_version"
+msgstr "Wersja"
+
+msgid "database_is_uptodate"
+msgstr "Baza danych jest aktualna."
+
+msgid "no_changes"
+msgstr "Brak zmian"
diff --git a/components/ILIAS/Component/lang/cmps_pt.po b/components/ILIAS/Component/lang/cmps_pt.po
new file mode 100644
index 000000000000..a70605414bca
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_pt.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: pt\n"
+"Language-Team: Portuguese\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Verifique o seu ficheiro de idioma"
+
+msgid "cmps_active"
+msgstr "Verificar todos os idiomas"
+
+msgid "cmps_add_new_rank"
+msgstr "Posição em lista Adicionar novos objetos"
+
+msgid "cmps_available"
+msgstr "Verificação do weblink"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Se estiver ativado, todos os links externos nos módulos de aprendizagem ILIAS serão verificados quanto á sua ativação."
+
+msgid "cmps_class_file"
+msgstr "Verificar as contas de utilizador"
+
+msgid "cmps_component"
+msgstr "Se estiver ativado, todos os utilizadores cujas contas expiram serão informados por e-mail duas semanas antes. Além disso, todos os utilizadores que não confirmaram a sua conta depois de um registo com confirmação de e-mail serão apagados."
+
+msgid "cmps_configure"
+msgstr "Configurar"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Verificar links de web"
+
+msgid "cmps_database"
+msgstr "Se estiver ativado, todos os weblinks serão verificados quanto à sua ativação."
+
+msgid "cmps_db_update"
+msgstr "Mudar palavra-passe da pasta web"
+
+msgid "cmps_deactivate"
+msgstr "Mudar palavra-passe ILIAS"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Mudar palavra-passe"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Ativar criação"
+
+msgid "cmps_file_version"
+msgstr "Atualizar versão de ficheiro"
+
+msgid "cmps_group"
+msgstr "Selecione seu idioma"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Selecione apenas um idioma"
+
+msgid "cmps_ilias_min_version"
+msgstr "Acesso de leitura à administração da sala de chat"
+
+msgid "cmps_inactive"
+msgstr "A administração da sala de chat está visível"
+
+msgid "cmps_install"
+msgstr "Instalar"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Copiar sala de chat"
+
+msgid "cmps_lang_prefix"
+msgstr "Cidade, estado"
+
+msgid "cmps_languages"
+msgstr "Editar administração da sala de chat"
+
+msgid "cmps_main_dir"
+msgstr "Limpar"
+
+msgid "cmps_missing"
+msgstr "Limpar área de transferência"
+
+msgid "cmps_module"
+msgstr "Cliente"
+
+msgid "cmps_must_installed"
+msgstr "O componente tem de estar instalado."
+
+msgid "cmps_name"
+msgstr "ID do cliente"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Esta versão de plugin apenas funciona em versões mais recentes de ILIAS. Atualize o ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Esta versão de ILIAS requer uma versão de plugin mais recente. Atualize o plugin."
+
+msgid "cmps_needs_update"
+msgstr "IP do cliente"
+
+msgid "cmps_needs_upgrade"
+msgstr "A versão de plugin atualmente instalada é mais antiga do que a versão anteriormente instalada. Atualize o plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Nenhum script de atualização DB disponível."
+
+msgid "cmps_no_language_file_available"
+msgstr "Nenhum ficheiro de idioma disponível."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "O plugin foi ativado."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Fechar"
+
+msgid "cmps_plugin_deactivated"
+msgstr "O plugin foi desativado."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "(%s Novo)"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Colapsado"
+
+msgid "cmps_plugin_slot"
+msgstr "Colunas"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Separado por vírgulas"
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Comentário"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Compor"
+
+msgid "cmps_repository_object_types"
+msgstr "Tipos de objeto do repositório"
+
+msgid "cmps_responsible"
+msgstr "Confirmar"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Contacto"
+
+msgid "cmps_service"
+msgstr "Informação de contacto"
+
+msgid "cmps_show_details"
+msgstr "Contactar a Assistência Técnica"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Estado"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Desinstalar"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Conteúdo"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "O plugin \"%1$s\" atualmente não pode ser ativado (\"%2$s\"). Neste estado, os plugins não podem ser desinstalados completamente. Como apenas a integração nuclear é revertida, os dados personalizados do plugin podem permanecer. Quer continuar com a desinstalação parcial?"
+
+msgid "cmps_update"
+msgstr "Atualizar"
+
+msgid "cmps_version"
+msgstr "Versão"
+
+msgid "database_is_uptodate"
+msgstr "Estilos de conteúdo"
+
+msgid "no_changes"
+msgstr "Contexto"
diff --git a/components/ILIAS/Component/lang/cmps_ro.po b/components/ILIAS/Component/lang/cmps_ro.po
new file mode 100644
index 000000000000..573e4a21fe88
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_ro.po
@@ -0,0 +1,305 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ro\n"
+"Language-Team: Romanian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+#, fuzzy
+msgid "cmps_activate"
+msgstr "Activate"
+
+#, fuzzy
+msgid "cmps_active"
+msgstr "Active"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+#, fuzzy
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+#, fuzzy
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+#, fuzzy
+msgid "cmps_class_file"
+msgstr "Class File"
+
+#, fuzzy
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+#, fuzzy
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+#, fuzzy
+msgid "cmps_database"
+msgstr "Database"
+
+#, fuzzy
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+#, fuzzy
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+#, fuzzy
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+#, fuzzy
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+#, fuzzy
+msgid "cmps_group"
+msgstr "Group"
+
+#, fuzzy
+msgid "cmps_id"
+msgstr "ID"
+
+#, fuzzy
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+#, fuzzy
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+#, fuzzy
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+#, fuzzy
+msgid "cmps_languages"
+msgstr "Languages"
+
+#, fuzzy
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+#, fuzzy
+msgid "cmps_missing"
+msgstr "Missing"
+
+#, fuzzy
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+#, fuzzy
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+#, fuzzy
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+#, fuzzy
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+#, fuzzy
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+#, fuzzy
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+#, fuzzy
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+#, fuzzy
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+#, fuzzy
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+#, fuzzy
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+#, fuzzy
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+#, fuzzy
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+#, fuzzy
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+#, fuzzy
+msgid "cmps_service"
+msgstr "Service"
+
+#, fuzzy
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+#, fuzzy
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+#, fuzzy
+msgid "cmps_update"
+msgstr "Update"
+
+#, fuzzy
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_ru.po b/components/ILIAS/Component/lang/cmps_ru.po
new file mode 100644
index 000000000000..66795fa51992
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_ru.po
@@ -0,0 +1,251 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ru\n"
+"Language-Team: Russian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Activate"
+
+msgid "cmps_active"
+msgstr "Active"
+
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+msgid "cmps_class_file"
+msgstr "Class File"
+
+msgid "cmps_component"
+msgstr "Component"
+
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+msgid "cmps_database"
+msgstr "Database"
+
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Директория"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+msgid "cmps_group"
+msgstr "Group"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+msgid "cmps_languages"
+msgstr "Languages"
+
+msgid "cmps_main_dir"
+msgstr "Главная директория"
+
+msgid "cmps_missing"
+msgstr "Missing"
+
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+msgid "cmps_plugin_slot"
+msgstr "Слот плагина"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Плагины"
+
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+msgid "cmps_service"
+msgstr "Service"
+
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Update"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_sk.po b/components/ILIAS/Component/lang/cmps_sk.po
new file mode 100644
index 000000000000..cfe193783c77
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_sk.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: sk\n"
+"Language-Team: Slovak\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktivovat"
+
+msgid "cmps_active"
+msgstr "Aktivní"
+
+msgid "cmps_add_new_rank"
+msgstr "Pozice v seznamu \"Přidat nový objekt\""
+
+msgid "cmps_available"
+msgstr "Dostupné"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Základní soubory"
+
+msgid "cmps_class_file"
+msgstr "Soubor třídy"
+
+msgid "cmps_component"
+msgstr "Komponenta"
+
+msgid "cmps_configure"
+msgstr "Konfigurovat"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Aktuální verze"
+
+msgid "cmps_database"
+msgstr "Databáze"
+
+msgid "cmps_db_update"
+msgstr "Skript aktualizace DB"
+
+msgid "cmps_deactivate"
+msgstr "Deaktivovat"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Adresář"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Verze akualizačního souboru"
+
+msgid "cmps_group"
+msgstr "Skupina"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. verze ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. verze ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Neaktivní"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Soubory jazyků"
+
+msgid "cmps_lang_prefix"
+msgstr "Označení jazykových proměnných"
+
+msgid "cmps_languages"
+msgstr "Jazyky"
+
+msgid "cmps_main_dir"
+msgstr "Hlavní adresář"
+
+msgid "cmps_missing"
+msgstr "Chybí"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Název"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin."
+
+msgid "cmps_needs_update"
+msgstr "Vyžaduje aktualizaci."
+
+msgid "cmps_needs_upgrade"
+msgstr "Verze pluginu kterou instalujete je starší než nainstalovaná verze. Aktualizujte prosím plugin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Není dostupný žádný skript aktualizace DB."
+
+msgid "cmps_no_language_file_available"
+msgstr "Žádný jazykový soubor není dostupný."
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Plugin byl aktivován."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Označení tabulek DB"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Plugin byl deaktivován."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Soubor pluginu"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Označení jazykových pluginů"
+
+msgid "cmps_plugin_slot"
+msgstr "Slot pluginu"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Pluginy"
+
+msgid "cmps_refresh"
+msgstr "Obnovení"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Typ objektu úložiště"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Zodpovědný"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Uložit nastavení"
+
+msgid "cmps_service"
+msgstr "Služba"
+
+msgid "cmps_show_details"
+msgstr "Zobrazit detaily"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Akualizovat"
+
+msgid "cmps_version"
+msgstr "Verze"
+
+msgid "database_is_uptodate"
+msgstr "Tato databáze je aktuální."
+
+msgid "no_changes"
+msgstr "Žádné změny"
diff --git a/components/ILIAS/Component/lang/cmps_sl.po b/components/ILIAS/Component/lang/cmps_sl.po
new file mode 100644
index 000000000000..8643d8aff74d
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_sl.po
@@ -0,0 +1,245 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: sl\n"
+"Language-Team: Slovenian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktiviraj"
+
+msgid "cmps_active"
+msgstr "Aktivno"
+
+msgid "cmps_add_new_rank"
+msgstr "Položaj na izbirnem seznamu objektov za dodajanje"
+
+msgid "cmps_available"
+msgstr "Razpoložljivo"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Osnovne datoteke"
+
+msgid "cmps_class_file"
+msgstr "Razredna datoteka"
+
+msgid "cmps_component"
+msgstr "Komponenta"
+
+msgid "cmps_configure"
+msgstr "Konfiguriraj"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Aktualna različica"
+
+msgid "cmps_database"
+msgstr "Baza podatkov"
+
+msgid "cmps_db_update"
+msgstr "Skripta za posodobitev baze podatkov"
+
+msgid "cmps_deactivate"
+msgstr "Onemogoči"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Seznam"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Vključi ustvarjanje"
+
+msgid "cmps_file_version"
+msgstr "Posodobi različico datoteke"
+
+msgid "cmps_group"
+msgstr "Skupina"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maksimalna različica ILIAS"
+
+msgid "cmps_ilias_min_version"
+msgstr "Minimalna različica ILIAS"
+
+msgid "cmps_inactive"
+msgstr "Neaktiven"
+
+msgid "cmps_install"
+msgstr "Namesti"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Jezikovna datoteka"
+
+msgid "cmps_lang_prefix"
+msgstr "Predpona jezikovnih spremenljivk"
+
+msgid "cmps_languages"
+msgstr "Jeziki"
+
+msgid "cmps_main_dir"
+msgstr "Glavni seznam"
+
+msgid "cmps_missing"
+msgstr "Manjka"
+
+msgid "cmps_module"
+msgstr "Module"
+
+msgid "cmps_must_installed"
+msgstr "Komponenta mora biti nameščena."
+
+msgid "cmps_name"
+msgstr "Ime"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Ta vtičnik se lahko uporablja samo z novejšo različico ILIAS. Posodobite različico ILIAS."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Ta različica ILIAS potrebuje novejšo različico vtičnika. Posodobite vtičnik."
+
+msgid "cmps_needs_update"
+msgstr "Potrebna je posodobitev"
+
+msgid "cmps_needs_upgrade"
+msgstr "Trenutno nameščena različica vtičnika je starejša od predhodno nameščene. Namestite aktualen vtičnik."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Skripte za posodobitev baze podatkov ni na voljo"
+
+msgid "cmps_no_language_file_available"
+msgstr "Nobena jezikovna datoteka ni na voljo"
+
+msgid "cmps_plugin"
+msgstr "Vtičnik"
+
+msgid "cmps_plugin_activated"
+msgstr "Vtičnik je bil aktiviran."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Predpone tabele baze podatkov"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Vtičnik je bil onemogočen."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Datoteka vtičnika"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Jezikovne predpone vtičnika"
+
+msgid "cmps_plugin_slot"
+msgstr "Reža vtičnika"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Vsi jezikovni vnosi in vnosi v bazo podatkov, ki se nanašajo na vtičnik, so bili izbrisani in vtičnik je onemogočen. Zdaj lahko s seznama ’Prilagajanje’ odstranite seznam in izvorno kodo vtičnika, da vtičnik dokončno odstranite."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Vtičniki"
+
+msgid "cmps_refresh"
+msgstr "Na novo naloži jezike"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Tip objekta v magazinu"
+
+msgid "cmps_repository_object_types"
+msgstr "Tipi objektov v magazinu"
+
+msgid "cmps_responsible"
+msgstr "Odgovoren"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Shrani"
+
+msgid "cmps_service"
+msgstr "Storitev"
+
+msgid "cmps_show_details"
+msgstr "Prikaži podrobnosti"
+
+msgid "cmps_slots"
+msgstr "Dodeljeni čas"
+
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+msgid "cmps_uninstall"
+msgstr "Odstrani"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Ali res želite odstraniti vtičnik \"%s\" in izbrisati vse povezane vnose v bazi podatkov?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Vtičnika \"%1$s\" se trenutno ne da aktivirati (\"%2$s\"). V tem statusu ni mogoče v celoti odstraniti vtičnikov. Ker je izbrisana samo sistemska integracija, lahko lastne datoteke vtičnika ostanejo. Ali želite nadaljevati z delno odstranitvijo?"
+
+msgid "cmps_update"
+msgstr "Posodobi"
+
+msgid "cmps_version"
+msgstr "Različica"
+
+msgid "database_is_uptodate"
+msgstr "Baza podatkov je posodobljena."
+
+msgid "no_changes"
+msgstr "Brez sprememb"
diff --git a/components/ILIAS/Component/lang/cmps_sq.po b/components/ILIAS/Component/lang/cmps_sq.po
new file mode 100644
index 000000000000..4672cb547d76
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_sq.po
@@ -0,0 +1,305 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: sq\n"
+"Language-Team: Albanian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+#, fuzzy
+msgid "cmps_activate"
+msgstr "Activate"
+
+#, fuzzy
+msgid "cmps_active"
+msgstr "Active"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+#, fuzzy
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+#, fuzzy
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+#, fuzzy
+msgid "cmps_class_file"
+msgstr "Class File"
+
+#, fuzzy
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+#, fuzzy
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+#, fuzzy
+msgid "cmps_database"
+msgstr "Database"
+
+#, fuzzy
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+#, fuzzy
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+#, fuzzy
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+#, fuzzy
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+#, fuzzy
+msgid "cmps_group"
+msgstr "Group"
+
+#, fuzzy
+msgid "cmps_id"
+msgstr "ID"
+
+#, fuzzy
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+#, fuzzy
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+#, fuzzy
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+#, fuzzy
+msgid "cmps_languages"
+msgstr "Languages"
+
+#, fuzzy
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+#, fuzzy
+msgid "cmps_missing"
+msgstr "Missing"
+
+#, fuzzy
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+#, fuzzy
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+#, fuzzy
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+#, fuzzy
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+#, fuzzy
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+#, fuzzy
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+#, fuzzy
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+#, fuzzy
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+#, fuzzy
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+#, fuzzy
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+#, fuzzy
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+#, fuzzy
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+#, fuzzy
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+#, fuzzy
+msgid "cmps_service"
+msgstr "Service"
+
+#, fuzzy
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+#, fuzzy
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+#, fuzzy
+msgid "cmps_update"
+msgstr "Update"
+
+#, fuzzy
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_sr.po b/components/ILIAS/Component/lang/cmps_sr.po
new file mode 100644
index 000000000000..588e9d1f9a56
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_sr.po
@@ -0,0 +1,305 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: sr\n"
+"Language-Team: Serbian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+#, fuzzy
+msgid "cmps_activate"
+msgstr "Activate"
+
+#, fuzzy
+msgid "cmps_active"
+msgstr "Active"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+#, fuzzy
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+#, fuzzy
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+#, fuzzy
+msgid "cmps_class_file"
+msgstr "Class File"
+
+#, fuzzy
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+#, fuzzy
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+#, fuzzy
+msgid "cmps_database"
+msgstr "Database"
+
+#, fuzzy
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+#, fuzzy
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+#, fuzzy
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+#, fuzzy
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+#, fuzzy
+msgid "cmps_group"
+msgstr "Group"
+
+#, fuzzy
+msgid "cmps_id"
+msgstr "ID"
+
+#, fuzzy
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+#, fuzzy
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+#, fuzzy
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+#, fuzzy
+msgid "cmps_languages"
+msgstr "Languages"
+
+#, fuzzy
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+#, fuzzy
+msgid "cmps_missing"
+msgstr "Missing"
+
+#, fuzzy
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+#, fuzzy
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+#, fuzzy
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+#, fuzzy
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+#, fuzzy
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+#, fuzzy
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+#, fuzzy
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+#, fuzzy
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+#, fuzzy
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+#, fuzzy
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+#, fuzzy
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+#, fuzzy
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+#, fuzzy
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+#, fuzzy
+msgid "cmps_service"
+msgstr "Service"
+
+#, fuzzy
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+#, fuzzy
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+#, fuzzy
+msgid "cmps_update"
+msgstr "Update"
+
+#, fuzzy
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_sv.po b/components/ILIAS/Component/lang/cmps_sv.po
new file mode 100644
index 000000000000..3d5e00d729c1
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_sv.po
@@ -0,0 +1,232 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: sv\n"
+"Language-Team: Swedish\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Aktivera"
+
+msgid "cmps_active"
+msgstr "Aktiv"
+
+msgid "cmps_add_new_rank"
+msgstr "Position i urvalslistan för objekt som ska läggas till"
+
+msgid "cmps_available"
+msgstr "Tillgänglig"
+
+msgid "cmps_available_version"
+msgstr "Tillgänglig version"
+
+msgid "cmps_basic_files"
+msgstr "Basisfiler"
+
+msgid "cmps_class_file"
+msgstr " Klassfil"
+
+msgid "cmps_component"
+msgstr "Komponent"
+
+msgid "cmps_configure"
+msgstr "Konfigurera"
+
+msgid "cmps_current_db_version"
+msgstr "Aktuell DB-version"
+
+msgid "cmps_current_version"
+msgstr "Aktuell version"
+
+msgid "cmps_database"
+msgstr "Databas"
+
+msgid "cmps_db_update"
+msgstr "Script för uppdatering av databas"
+
+msgid "cmps_deactivate"
+msgstr "Avaktivera"
+
+msgid "cmps_detailed_information"
+msgstr "Detaljerad information"
+
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+msgid "cmps_enable_creation"
+msgstr "Starta skapandet"
+
+msgid "cmps_file_version"
+msgstr "Uppdatera filversion"
+
+msgid "cmps_group"
+msgstr "Grupp"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maximal ILIAS-version"
+
+msgid "cmps_ilias_min_version"
+msgstr "Minsta ILIAS-version"
+
+msgid "cmps_inactive"
+msgstr "Inaktiv"
+
+msgid "cmps_install"
+msgstr "Installera"
+
+msgid "cmps_is_active"
+msgstr "Aktiverad"
+
+msgid "cmps_is_installed"
+msgstr "Installerad"
+
+msgid "cmps_lang_files"
+msgstr "Språkfil"
+
+msgid "cmps_lang_prefix"
+msgstr "Prefix språkvariabel"
+
+msgid "cmps_languages"
+msgstr "Språk"
+
+msgid "cmps_main_dir"
+msgstr "Huvudkatalog"
+
+msgid "cmps_missing"
+msgstr "Saknad"
+
+msgid "cmps_module"
+msgstr "Modul"
+
+msgid "cmps_must_installed"
+msgstr "Komponenten måste vara installerad."
+
+msgid "cmps_name"
+msgstr "Namn"
+
+msgid "cmps_needs_matching_ilias_version"
+msgstr "Detta plugin kan inte användas med den aktuella versionen av denna ILIAS-installation."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Detta plugin kan endast användas med en nyare ILIAS-version. Vänligen uppdatera din ILIAS-version."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Den här ILIAS-versionen kräver en nyare plugin-version. Vänligen uppdatera pluginet."
+
+msgid "cmps_needs_update"
+msgstr "Uppdatering nödvändig"
+
+msgid "cmps_needs_upgrade"
+msgstr "Den nuvarande installerade plugin-versionen är äldre än den tidigare installerade. Vänligen installera det senaste pluginet."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Inget skript för uppdatering av databas tillgängligt"
+
+msgid "cmps_no_language_file_available"
+msgstr "Ingen språkfil tillgänglig"
+
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+msgid "cmps_plugin_activated"
+msgstr "Insticksprogrammet har aktiverats."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "Prefixar databastabell"
+
+msgid "cmps_plugin_deactivated"
+msgstr "Insticksprogrammet har avaktiverats."
+
+msgid "cmps_plugin_deinstalled"
+msgstr "Insticksprogrammet har avinstallerats"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin-fil"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin språk prefix"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin-plats"
+
+msgid "cmps_plugin_uninstalled"
+msgstr "Alla språk och databasposter relaterade till pluginet har tagits bort och pluginet har avaktiverats. Du kan nu ta bort katalogen och källkoden för insticksprogrammet från katalogen \"Customizing\" för att slutligen avinstallera insticksprogrammet."
+
+msgid "cmps_plugin_updated"
+msgstr "Insticksprogrammet har uppdaterats"
+
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+msgid "cmps_refresh"
+msgstr "Reload språk"
+
+msgid "cmps_refresh_lng"
+msgstr "Språk uppdaterat"
+
+msgid "cmps_rep_object"
+msgstr "Objekttyp i tidningen"
+
+msgid "cmps_repository_object_types"
+msgstr "Objekttyper i tidningen"
+
+msgid "cmps_responsible"
+msgstr "Ansvarsfull"
+
+msgid "cmps_responsible_mail"
+msgstr "Mail (ansvarig person)"
+
+msgid "cmps_save_options"
+msgstr "Spara"
+
+msgid "cmps_service"
+msgstr "Tjänst"
+
+msgid "cmps_show_details"
+msgstr "Visa detaljer"
+
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Status"
+
+msgid "cmps_supports_cli_setup"
+msgstr "Stödjer CLI-installation"
+
+msgid "cmps_supports_export"
+msgstr "Stödd export"
+
+msgid "cmps_supports_learning_progress"
+msgstr "Stödjer framsteg i inlärningen"
+
+msgid "cmps_uninstall"
+msgstr "Avinstallera"
+
+msgid "cmps_uninstall_confirm"
+msgstr "Vill du verkligen avinstallera insticksprogrammet \"%s\" och ta bort alla tillhörande databasposter?"
+
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "Insticksprogrammet \"%1$s\" kan för närvarande inte aktiveras (\"%2$s\"). I denna status kan plugins inte avinstalleras helt. Eftersom endast systemintegrationen tas bort kan data som ägs av plugin finnas kvar. Vill du fortsätta med den partiella avinstallat"
+
+msgid "cmps_update"
+msgstr "Uppdatera"
+
+msgid "cmps_version"
+msgstr "Version"
+
+msgid "database_is_uptodate"
+msgstr "Databasen är uppdaterad."
+
+msgid "no_changes"
+msgstr "Inga förändringar"
diff --git a/components/ILIAS/Component/lang/cmps_tr.po b/components/ILIAS/Component/lang/cmps_tr.po
new file mode 100644
index 000000000000..ce3b24a356b2
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_tr.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: tr\n"
+"Language-Team: Turkish\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "Etkinleştir"
+
+msgid "cmps_active"
+msgstr "Aktif"
+
+msgid "cmps_add_new_rank"
+msgstr "Yeni Nesne Listesi Pozisyonu"
+
+msgid "cmps_available"
+msgstr "Mevcut"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "Temel Dosyalar"
+
+msgid "cmps_class_file"
+msgstr "Sınıf Dosyası"
+
+msgid "cmps_component"
+msgstr "Bileşen"
+
+msgid "cmps_configure"
+msgstr "Yapılandırma"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "Güncel Sürüm"
+
+msgid "cmps_database"
+msgstr "Veritabanı"
+
+msgid "cmps_db_update"
+msgstr "DB Güncelleme Senaryo"
+
+msgid "cmps_deactivate"
+msgstr "Devre dışı bırak"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "Dizin"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "Güncelleme Dosya Sürümü"
+
+msgid "cmps_group"
+msgstr "Grubu"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Maks. ILIAS Sürüm"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Sürüm"
+
+msgid "cmps_inactive"
+msgstr "İnaktif"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "Lisan Dosyalar"
+
+msgid "cmps_lang_prefix"
+msgstr "Dil Değişkenleri Önek"
+
+msgid "cmps_languages"
+msgstr "Diller"
+
+msgid "cmps_main_dir"
+msgstr "Ana Dizin"
+
+msgid "cmps_missing"
+msgstr "Eksik"
+
+msgid "cmps_module"
+msgstr "Modülü"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "Adı"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "Bu eklenti sürümü sadece yeni ILIAS sürümlerinde çalışır. ILIAS yükseltin."
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "Bu ILIAS sürümü daha yeni bir eklenti sürümü gerekiyor. Eklenti yükseltin."
+
+msgid "cmps_needs_update"
+msgstr "İhtiyaçları güncelleme."
+
+msgid "cmps_needs_upgrade"
+msgstr "Yüklü eklenti sürümü önceden yüklenmiş sürümü daha eski. Eklenti yükseltin."
+
+msgid "cmps_no_db_update_file_available"
+msgstr "Mevcut Resim DB güncelleştirme komut dosyası."
+
+msgid "cmps_no_language_file_available"
+msgstr "Mevcut dil dosyası yok."
+
+msgid "cmps_plugin"
+msgstr "Eklentisi"
+
+msgid "cmps_plugin_activated"
+msgstr "eklentisi devreye alınmıştır."
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB tablo Önekler"
+
+msgid "cmps_plugin_deactivated"
+msgstr "eklentisi devrede değil."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "Plugin Dosyası"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Dil Önekler"
+
+msgid "cmps_plugin_slot"
+msgstr "Plugin Yuvası"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "Eklentiler"
+
+msgid "cmps_refresh"
+msgstr "Yenile"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "Depo Nesne Türü"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Sorumlu"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "Seçenekleri Kaydet"
+
+msgid "cmps_service"
+msgstr "Hizmet"
+
+msgid "cmps_show_details"
+msgstr "Detayları göster"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "Durumu"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "Güncelleme"
+
+msgid "cmps_version"
+msgstr "Sürüm"
+
+msgid "database_is_uptodate"
+msgstr "veritabanı güncel olduğunu."
+
+msgid "no_changes"
+msgstr "Hayır değişiklikler"
diff --git a/components/ILIAS/Component/lang/cmps_uk.po b/components/ILIAS/Component/lang/cmps_uk.po
new file mode 100644
index 000000000000..19e0610fdf16
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_uk.po
@@ -0,0 +1,305 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: uk\n"
+"Language-Team: Ukrainian\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
+"X-Domain: cmps\n"
+
+#, fuzzy
+msgid "cmps_activate"
+msgstr "Activate"
+
+#, fuzzy
+msgid "cmps_active"
+msgstr "Active"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+#, fuzzy
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+#, fuzzy
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+#, fuzzy
+msgid "cmps_class_file"
+msgstr "Class File"
+
+#, fuzzy
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+#, fuzzy
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+#, fuzzy
+msgid "cmps_database"
+msgstr "Database"
+
+#, fuzzy
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+#, fuzzy
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+#, fuzzy
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+#, fuzzy
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+#, fuzzy
+msgid "cmps_group"
+msgstr "Group"
+
+#, fuzzy
+msgid "cmps_id"
+msgstr "ID"
+
+#, fuzzy
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+#, fuzzy
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+#, fuzzy
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+#, fuzzy
+msgid "cmps_languages"
+msgstr "Languages"
+
+#, fuzzy
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+#, fuzzy
+msgid "cmps_missing"
+msgstr "Missing"
+
+#, fuzzy
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+#, fuzzy
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+#, fuzzy
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+#, fuzzy
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+#, fuzzy
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+#, fuzzy
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+#, fuzzy
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+#, fuzzy
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+#, fuzzy
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+#, fuzzy
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+#, fuzzy
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+#, fuzzy
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+#, fuzzy
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+#, fuzzy
+msgid "cmps_service"
+msgstr "Service"
+
+#, fuzzy
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+#, fuzzy
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+#, fuzzy
+msgid "cmps_update"
+msgstr "Update"
+
+#, fuzzy
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_vi.po b/components/ILIAS/Component/lang/cmps_vi.po
new file mode 100644
index 000000000000..fa8d4e1eacf2
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_vi.po
@@ -0,0 +1,305 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: vi\n"
+"Language-Team: Vietnamese\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Domain: cmps\n"
+
+#, fuzzy
+msgid "cmps_activate"
+msgstr "Activate"
+
+#, fuzzy
+msgid "cmps_active"
+msgstr "Active"
+
+#, fuzzy
+msgid "cmps_add_new_rank"
+msgstr "Position in Add-New-Object List"
+
+#, fuzzy
+msgid "cmps_available"
+msgstr "Available"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+#, fuzzy
+msgid "cmps_basic_files"
+msgstr "Basic Files"
+
+#, fuzzy
+msgid "cmps_class_file"
+msgstr "Class File"
+
+#, fuzzy
+msgid "cmps_component"
+msgstr "Component"
+
+#, fuzzy
+msgid "cmps_configure"
+msgstr "Configure"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+#, fuzzy
+msgid "cmps_current_version"
+msgstr "Current Version"
+
+#, fuzzy
+msgid "cmps_database"
+msgstr "Database"
+
+#, fuzzy
+msgid "cmps_db_update"
+msgstr "DB Update Script"
+
+#, fuzzy
+msgid "cmps_deactivate"
+msgstr "Deactivate"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+#, fuzzy
+msgid "cmps_dir"
+msgstr "Directory"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+#, fuzzy
+msgid "cmps_file_version"
+msgstr "Update File Version"
+
+#, fuzzy
+msgid "cmps_group"
+msgstr "Group"
+
+#, fuzzy
+msgid "cmps_id"
+msgstr "ID"
+
+#, fuzzy
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS Version"
+
+#, fuzzy
+msgid "cmps_inactive"
+msgstr "Inactive"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+#, fuzzy
+msgid "cmps_lang_files"
+msgstr "Language Files"
+
+#, fuzzy
+msgid "cmps_lang_prefix"
+msgstr "Language Variables Prefix"
+
+#, fuzzy
+msgid "cmps_languages"
+msgstr "Languages"
+
+#, fuzzy
+msgid "cmps_main_dir"
+msgstr "Main Directory"
+
+#, fuzzy
+msgid "cmps_missing"
+msgstr "Missing"
+
+#, fuzzy
+msgid "cmps_module"
+msgstr "Module"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+#, fuzzy
+msgid "cmps_name"
+msgstr "Name"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+#, fuzzy
+msgid "cmps_needs_newer_ilias_version"
+msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS."
+
+#, fuzzy
+msgid "cmps_needs_newer_plugin_version"
+msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_needs_update"
+msgstr "Needs update."
+
+#, fuzzy
+msgid "cmps_needs_upgrade"
+msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin."
+
+#, fuzzy
+msgid "cmps_no_db_update_file_available"
+msgstr "No DB update script available."
+
+#, fuzzy
+msgid "cmps_no_language_file_available"
+msgstr "No language file available."
+
+#, fuzzy
+msgid "cmps_plugin"
+msgstr "Plugin"
+
+#, fuzzy
+msgid "cmps_plugin_activated"
+msgstr "The plugin has been activated."
+
+#, fuzzy
+msgid "cmps_plugin_db_prefixes"
+msgstr "DB Table Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_deactivated"
+msgstr "The plugin has been deactivated."
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+#, fuzzy
+msgid "cmps_plugin_file"
+msgstr "Plugin File"
+
+#, fuzzy
+msgid "cmps_plugin_lang_prefixes"
+msgstr "Plugin Language Prefixes"
+
+#, fuzzy
+msgid "cmps_plugin_slot"
+msgstr "Plugin Slot"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+#, fuzzy
+msgid "cmps_plugins"
+msgstr "Plugins"
+
+#, fuzzy
+msgid "cmps_refresh"
+msgstr "Refresh"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+#, fuzzy
+msgid "cmps_rep_object"
+msgstr "Repository Object Type"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+#, fuzzy
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+#, fuzzy
+msgid "cmps_save_options"
+msgstr "Save Options"
+
+#, fuzzy
+msgid "cmps_service"
+msgstr "Service"
+
+#, fuzzy
+msgid "cmps_show_details"
+msgstr "Show Details"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+#, fuzzy
+msgid "cmps_status"
+msgstr "Status"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+#, fuzzy
+msgid "cmps_update"
+msgstr "Update"
+
+#, fuzzy
+msgid "cmps_version"
+msgstr "Version"
+
+#, fuzzy
+msgid "database_is_uptodate"
+msgstr "Die Datenbank ist aktuell."
+
+#, fuzzy
+msgid "no_changes"
+msgstr "Keine Änderungen"
diff --git a/components/ILIAS/Component/lang/cmps_zh.po b/components/ILIAS/Component/lang/cmps_zh.po
new file mode 100644
index 000000000000..6858f22f1964
--- /dev/null
+++ b/components/ILIAS/Component/lang/cmps_zh.po
@@ -0,0 +1,254 @@
+msgid ""
+msgstr ""
+"Content-Transfer-Encoding: 8bit\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: zh\n"
+"Language-Team: Chinese\n"
+"MIME-Version: 1.0\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Domain: cmps\n"
+
+msgid "cmps_activate"
+msgstr "激活"
+
+msgid "cmps_active"
+msgstr "激活"
+
+msgid "cmps_add_new_rank"
+msgstr "在“添加新对象”列表中的位置"
+
+msgid "cmps_available"
+msgstr "有效的"
+
+#, fuzzy
+msgid "cmps_available_version"
+msgstr "Available Version"
+
+msgid "cmps_basic_files"
+msgstr "基本文件"
+
+msgid "cmps_class_file"
+msgstr "类文件"
+
+msgid "cmps_component"
+msgstr "组件"
+
+msgid "cmps_configure"
+msgstr "配置"
+
+#, fuzzy
+msgid "cmps_current_db_version"
+msgstr "Current DB-Version"
+
+msgid "cmps_current_version"
+msgstr "当前版本"
+
+msgid "cmps_database"
+msgstr "数据库"
+
+msgid "cmps_db_update"
+msgstr "数据库更新脚本"
+
+msgid "cmps_deactivate"
+msgstr "停用"
+
+#, fuzzy
+msgid "cmps_detailed_information"
+msgstr "Detailed Informationen"
+
+msgid "cmps_dir"
+msgstr "目录"
+
+#, fuzzy
+msgid "cmps_disable_creation"
+msgstr "Disable Creation"
+
+#, fuzzy
+msgid "cmps_enable_creation"
+msgstr "Enable Creation"
+
+msgid "cmps_file_version"
+msgstr "更新文件版本"
+
+msgid "cmps_group"
+msgstr "用户组"
+
+msgid "cmps_id"
+msgstr "ID"
+
+msgid "cmps_ilias_max_version"
+msgstr "Max. ILIAS 版本"
+
+msgid "cmps_ilias_min_version"
+msgstr "Min. ILIAS 版本"
+
+msgid "cmps_inactive"
+msgstr "不活跃的"
+
+#, fuzzy
+msgid "cmps_install"
+msgstr "Install"
+
+#, fuzzy
+msgid "cmps_is_active"
+msgstr "Activated"
+
+#, fuzzy
+msgid "cmps_is_installed"
+msgstr "Installed"
+
+msgid "cmps_lang_files"
+msgstr "语言文件"
+
+msgid "cmps_lang_prefix"
+msgstr "语言变量前缀"
+
+msgid "cmps_languages"
+msgstr "语言"
+
+msgid "cmps_main_dir"
+msgstr "主目录"
+
+msgid "cmps_missing"
+msgstr "缺失"
+
+msgid "cmps_module"
+msgstr "模块"
+
+#, fuzzy
+msgid "cmps_must_installed"
+msgstr "The component must be installed."
+
+msgid "cmps_name"
+msgstr "名字"
+
+#, fuzzy
+msgid "cmps_needs_matching_ilias_version"
+msgstr "This plugin does not work with your current ILIAS version."
+
+msgid "cmps_needs_newer_ilias_version"
+msgstr "这个插件版本仅运行在更新的ILIAS版本上。请升级ILIAS。"
+
+msgid "cmps_needs_newer_plugin_version"
+msgstr "这个ILIAS版本需要一个更新的插件版本。请升级插件。"
+
+msgid "cmps_needs_update"
+msgstr "需要更新。"
+
+msgid "cmps_needs_upgrade"
+msgstr "当前已安装的插件版本比先前安装的版本更老,请升级插件。"
+
+msgid "cmps_no_db_update_file_available"
+msgstr "没有可用的数据库升级脚本。"
+
+msgid "cmps_no_language_file_available"
+msgstr "没有可用的语言文件。"
+
+msgid "cmps_plugin"
+msgstr "插件"
+
+msgid "cmps_plugin_activated"
+msgstr "插件已激活"
+
+msgid "cmps_plugin_db_prefixes"
+msgstr "数据库表前缀"
+
+msgid "cmps_plugin_deactivated"
+msgstr "插件已停用"
+
+#, fuzzy
+msgid "cmps_plugin_deinstalled"
+msgstr "The plugin has been uninstalled"
+
+msgid "cmps_plugin_file"
+msgstr "插件文件"
+
+msgid "cmps_plugin_lang_prefixes"
+msgstr "插件语言前缀"
+
+msgid "cmps_plugin_slot"
+msgstr "插件插槽(Slot)"
+
+#, fuzzy
+msgid "cmps_plugin_uninstalled"
+msgstr "The plugin has been uninstalled."
+
+#, fuzzy
+msgid "cmps_plugin_updated"
+msgstr "The plugin has been updated"
+
+msgid "cmps_plugins"
+msgstr "插件"
+
+msgid "cmps_refresh"
+msgstr "刷新"
+
+#, fuzzy
+msgid "cmps_refresh_lng"
+msgstr "Refresh language"
+
+msgid "cmps_rep_object"
+msgstr "库对象类型"
+
+#, fuzzy
+msgid "cmps_repository_object_types"
+msgstr "Repository Object Types"
+
+msgid "cmps_responsible"
+msgstr "Responsible"
+
+#, fuzzy
+msgid "cmps_responsible_mail"
+msgstr "Mail (Responsible)"
+
+msgid "cmps_save_options"
+msgstr "保存选项"
+
+msgid "cmps_service"
+msgstr "服务"
+
+msgid "cmps_show_details"
+msgstr "细节展示"
+
+#, fuzzy
+msgid "cmps_slots"
+msgstr "Slots"
+
+msgid "cmps_status"
+msgstr "状态"
+
+#, fuzzy
+msgid "cmps_supports_cli_setup"
+msgstr "Supports CLI-Setup"
+
+#, fuzzy
+msgid "cmps_supports_export"
+msgstr "Supports export"
+
+#, fuzzy
+msgid "cmps_supports_learning_progress"
+msgstr "Supports learning progress"
+
+#, fuzzy
+msgid "cmps_uninstall"
+msgstr "Uninstall"
+
+#, fuzzy
+msgid "cmps_uninstall_confirm"
+msgstr "Are you sure you want to uninstall the plugin \"%s\"?"
+
+#, fuzzy
+msgid "cmps_uninstall_inactive_confirm"
+msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?"
+
+msgid "cmps_update"
+msgstr "更新"
+
+msgid "cmps_version"
+msgstr "版本"
+
+msgid "database_is_uptodate"
+msgstr "该数据库是当前数据库"
+
+msgid "no_changes"
+msgstr "没有变化"
diff --git a/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php b/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php
index 1c82a206fa90..3b84f027b675 100755
--- a/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php
+++ b/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php
@@ -139,7 +139,7 @@ public function testPluginsAdded(): void
public function testScanDir(): void
{
// Use the component directory without artifacts, because this should be mostly stable.
- $expected = ["Component.php", "PRIVACY.md", "README.md", "ROADMAP.md", "classes", "exceptions", "service.xml", "src", "tests", "docs"];
+ $expected = ["Component.php", "PRIVACY.md", "README.md", "ROADMAP.md", "classes", "exceptions", "lang", "service.xml", "src", "tests", "docs"];
$actual = array_values(
array_diff(
$this->builder->_scanDir(__DIR__ . "/../.."),
diff --git a/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php b/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php
index 4e244bcc508d..b703a317907f 100755
--- a/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php
+++ b/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php
@@ -43,8 +43,8 @@ public function addEditFormCustomProperties(ilPropertyFormGUI $form): void
$rd_template = new ilRadioGroupInputGUI($lng->txt("exc_template"), "template");
$rd_template->setRequired(true);
- $radio_no_template = new ilRadioOption($lng->txt("exc_without_template"), "0", $lng->txt("exc_without_template_info", "without_template_info"));
- $radio_with_template = new ilRadioOption($lng->txt("exc_with_template"), "1", $lng->txt("exc_with_template_info", "with_template_info"));
+ $radio_no_template = new ilRadioOption($lng->txt("exc_without_template"), "0", $lng->txt("exc_without_template_info"));
+ $radio_with_template = new ilRadioOption($lng->txt("exc_with_template"), "1", $lng->txt("exc_with_template_info"));
$repo = new ilRepositorySelector2InputGUI($lng->txt("exc_portfolio_template"), "template_id");
$repo->setRequired(true);
diff --git a/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php b/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php
index b6ebd64873ef..607a434e0d4e 100755
--- a/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php
+++ b/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php
@@ -627,7 +627,7 @@ protected function askUnlinkPortfolioObject(): void
$conf = new ilConfirmationGUI();
$conf->setFormAction($this->ctrl->getFormAction($this, "unlinkPortfolio"));
- $conf->setHeaderText($this->lng->txt("exc_sure_unlink_portfolio", "sure_unlink_portfolio"));
+ $conf->setHeaderText($this->lng->txt("exc_sure_unlink_portfolio"));
$conf->setConfirm($this->lng->txt("confirm"), "unlinkPortfolio");
$conf->setCancel($this->lng->txt("cancel"), "returnToParent");
diff --git a/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php b/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php
index 2198eb2b7fbb..f69d6113d76f 100755
--- a/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php
+++ b/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php
@@ -94,7 +94,7 @@ public function initTopicsOverviewTable(): void
$this->addColumn($this->lng->txt('visits'), 'num_visit');
if ($this->is_post_draft_allowed) {
- $this->addColumn($this->lng->txt('drafts', ''));
+ $this->addColumn($this->lng->txt('drafts'));
}
$this->addColumn($this->lng->txt('forums_last_post'), 'post_date');
@@ -135,7 +135,7 @@ public function initMergeThreadsTable(): void
$this->addColumn($this->lng->txt('forums_articles'), 'num_posts');
$this->addColumn($this->lng->txt('visits'), 'num_visit');
if ($this->is_post_draft_allowed) {
- $this->addColumn($this->lng->txt('drafts', ''));
+ $this->addColumn($this->lng->txt('drafts'));
}
$this->addColumn($this->lng->txt('forums_last_post'), 'lp_date');
diff --git a/components/ILIAS/Init/Init.php b/components/ILIAS/Init/Init.php
index a33d7222552d..f86b449c6cee 100644
--- a/components/ILIAS/Init/Init.php
+++ b/components/ILIAS/Init/Init.php
@@ -173,7 +173,15 @@ public function init(
$use[Logging\Logger\LoggerFactoryInterface::class],
$use[Logging\Logger\DefaultConfigLoggerFactoryInterface::class],
$use[Logging\Config\ConfigInterface::class],
- $use[\ILIAS\LTI\View::class]
+ $use[\ILIAS\LTI\View::class],
+ $pull[\ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager::class],
+ $pull[\ILIAS\Language\Activities\InstallLanguage::class],
+ $pull[\ILIAS\Language\Activities\UpdateLanguage::class],
+ $pull[\ILIAS\Language\Activities\UninstallLanguage::class],
+ $pull[\ILIAS\Language\Activities\RemoveLocalLanguageChanges::class],
+ $pull[\ILIAS\Language\Activities\AddLanguageEntry::class],
+ $pull[\ILIAS\Language\Activities\SetLanguageDetectionEnabled::class],
+ $pull[\ILIAS\Language\Activities\SetLanguageTranslationEnabled::class],
);
}
}
diff --git a/components/ILIAS/Init/src/AllModernComponents.php b/components/ILIAS/Init/src/AllModernComponents.php
index e50f113ad099..0126698d05fe 100644
--- a/components/ILIAS/Init/src/AllModernComponents.php
+++ b/components/ILIAS/Init/src/AllModernComponents.php
@@ -144,7 +144,15 @@ public function __construct(
protected Logging\Logger\LoggerFactoryInterface $logger_factory,
protected Logging\Logger\DefaultConfigLoggerFactoryInterface $default_config_logger_factory,
protected Logging\Config\ConfigInterface $logging_config,
- protected \ILIAS\LTI\View $lti_view
+ protected \ILIAS\LTI\View $lti_view,
+ protected \ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager $language_file_directory_manager,
+ protected \ILIAS\Language\Activities\InstallLanguage $install_language,
+ protected \ILIAS\Language\Activities\UpdateLanguage $update_language,
+ protected \ILIAS\Language\Activities\UninstallLanguage $uninstall_language,
+ protected \ILIAS\Language\Activities\RemoveLocalLanguageChanges $remove_local_language_changes,
+ protected \ILIAS\Language\Activities\AddLanguageEntry $add_language_entry,
+ protected \ILIAS\Language\Activities\SetLanguageDetectionEnabled $set_language_detection_enabled,
+ protected \ILIAS\Language\Activities\SetLanguageTranslationEnabled $set_language_translation_enabled,
) {
}
@@ -257,6 +265,14 @@ protected function populateComponentsInLegacyEnvironment(\Pimple\Container $DIC)
$DIC['logging.defaultConfigFactory'] = fn(): Logging\Logger\DefaultConfigLoggerFactoryInterface => $this->default_config_logger_factory;
$DIC['logging.config'] = fn(): Logging\Config\ConfigInterface => $this->logging_config;
$DIC['lti'] = fn(): \ILIAS\LTI\View => $this->lti_view;
+ $DIC[\ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager::class] = fn(): \ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager => $this->language_file_directory_manager;
+ $DIC[\ILIAS\Language\Activities\InstallLanguage::class] = fn(): \ILIAS\Language\Activities\InstallLanguage => $this->install_language;
+ $DIC[\ILIAS\Language\Activities\UpdateLanguage::class] = fn(): \ILIAS\Language\Activities\UpdateLanguage => $this->update_language;
+ $DIC[\ILIAS\Language\Activities\UninstallLanguage::class] = fn(): \ILIAS\Language\Activities\UninstallLanguage => $this->uninstall_language;
+ $DIC[\ILIAS\Language\Activities\RemoveLocalLanguageChanges::class] = fn(): \ILIAS\Language\Activities\RemoveLocalLanguageChanges => $this->remove_local_language_changes;
+ $DIC[\ILIAS\Language\Activities\AddLanguageEntry::class] = fn(): \ILIAS\Language\Activities\AddLanguageEntry => $this->add_language_entry;
+ $DIC[\ILIAS\Language\Activities\SetLanguageDetectionEnabled::class] = fn(): \ILIAS\Language\Activities\SetLanguageDetectionEnabled => $this->set_language_detection_enabled;
+ $DIC[\ILIAS\Language\Activities\SetLanguageTranslationEnabled::class] = fn(): \ILIAS\Language\Activities\SetLanguageTranslationEnabled => $this->set_language_translation_enabled;
}
public function getName(): string
diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php
index 2cbc888fc36a..256093c9f975 100644
--- a/components/ILIAS/Language/Language.php
+++ b/components/ILIAS/Language/Language.php
@@ -20,6 +20,25 @@
namespace ILIAS;
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory;
+use ILIAS\Language\Activities\InstallLanguage;
+use ILIAS\Language\Activities\UpdateLanguage;
+use ILIAS\Language\Activities\UninstallLanguage;
+use ILIAS\Language\Activities\RemoveLocalLanguageChanges;
+use ILIAS\Language\Activities\AddLanguageEntry;
+use ILIAS\Language\Activities\SetLanguageDetectionEnabled;
+use ILIAS\Language\Activities\SetLanguageTranslationEnabled;
+use ILIAS\Language\Activities\RepositoryProvider;
+use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository;
+use ILIAS\Environment\Configuration\Installation\Directories;
+use ILIAS\Environment\Configuration\Installation\ClientIdProvider;
+use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths;
+use ILIAS\Logging\Logger\LoggerFactoryInterface;
+
class Language implements Component\Component
{
public function init(
@@ -34,25 +53,174 @@ public function init(
): void {
$define[] = \ILIAS\Language\Language::class;
+ // each entry's dependencies are declared above it
+ $internal[Language\LanguageFolder::class] = static fn() =>
+ new Language\LanguageFolderLegacyProxy();
+
+ $internal[LanguageFileDirectoryManager::class] = static fn() =>
+ new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ ...$seek[LanguageFileDirectory::class]
+ );
+
+ $internal[InstalledLanguageDatabaseRepository::class] = static fn() =>
+ new InstalledLanguageDatabaseRepository(
+ $pull[\ILIAS\Database\Connection::class],
+ $internal[LanguageFileDirectoryManager::class],
+ new Language\Setup\DirectoriesInstallationRoot($use[Directories::class])
+ );
+
+ // Setup hands in its own database via setDbHandler() instead of the Connection
+ $internal[\ilSetupLanguage::class] = static fn() =>
+ new \ilSetupLanguage(
+ "en",
+ $internal[LanguageFileDirectoryManager::class]
+ );
+
+ $internal[Language\Setup\TranslationCache::class] = static fn() =>
+ new Language\Setup\TranslationCacheLegacyProxy();
+
+ // $use must be read inside a container closure: the bootstrap only wires what is read there
+ $client_data_dir_resolver_of = static function (
+ Directories $directories,
+ ClientIdProvider $client_id_provider
+ ): \Closure {
+ return static function () use ($directories, $client_id_provider): ?string {
+ try {
+ $client_id = $client_id_provider->getClientId()->toString();
+ } catch (\InvalidArgumentException) {
+ // Missing or invalid client id: no overlay instead of a failing request.
+ return null;
+ }
+ return MigratedLanguageFilePaths::fromDataDirAndClientId($directories->getDataDir(), $client_id);
+ };
+ };
+
+ $runtime_setup_language = static fn(): \ilSetupLanguage =>
+ new \ilSetupLanguage(
+ "en",
+ $internal[LanguageFileDirectoryManager::class],
+ $pull[\ILIAS\Database\Connection::class],
+ $internal[Language\Setup\TranslationCache::class],
+ $use[LoggerFactoryInterface::class]->getLazy('lang'),
+ $client_data_dir_resolver_of($use[Directories::class], $use[ClientIdProvider::class])
+ );
+
+ $internal[Language\Activities\LanguageSettings::class] = static fn() =>
+ new Language\Activities\LanguageSettingsLegacyProxy();
+
+ // the runtime Activities route all legacy access through this provider
+ $internal[RepositoryProvider::class] = static fn() =>
+ new RepositoryProvider(
+ $internal[InstalledLanguageDatabaseRepository::class],
+ $pull[\ILIAS\Database\Connection::class],
+ $internal[Language\Activities\LanguageSettings::class],
+ $internal[LanguageFileDirectoryManager::class],
+ new Language\Setup\DirectoriesInstallationRoot($use[Directories::class]),
+ $client_data_dir_resolver_of($use[Directories::class], $use[ClientIdProvider::class]),
+ $use[LoggerFactoryInterface::class]->getLazy('lang'),
+ );
+
+ $internal[InstallLanguage::class] = static fn() =>
+ new InstallLanguage(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $runtime_setup_language(),
+ $internal[Language\LanguageFolder::class]
+ );
+
+ $internal[UpdateLanguage::class] = static fn() =>
+ new UpdateLanguage(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $runtime_setup_language(),
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ $internal[UninstallLanguage::class] = static fn() =>
+ new UninstallLanguage(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ $internal[RemoveLocalLanguageChanges::class] = static fn() =>
+ new RemoveLocalLanguageChanges(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ $internal[AddLanguageEntry::class] = static fn() =>
+ new AddLanguageEntry(
+ $use[\ILIAS\Language\Language::class],
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ $internal[SetLanguageDetectionEnabled::class] = static fn() =>
+ new SetLanguageDetectionEnabled(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ $internal[SetLanguageTranslationEnabled::class] = static fn() =>
+ new SetLanguageTranslationEnabled(
+ $use[\ILIAS\AccessControl\PublicInterface\RBAC::class],
+ $internal[Language\LanguageFolder::class],
+ $internal[RepositoryProvider::class],
+ );
+
+ // only proxies to $DIC->language() at call time, hence no constructor arguments
+ $internal[Language\LanguageLegacyInitialisationAdapter::class] = static fn() =>
+ new Language\LanguageLegacyInitialisationAdapter();
+
+ // Two candidates on purpose, neither line is dead: every $implement assignment is collected,
+ // and each bootstrap entry point picks one in its resources/dependency_resolution.php
+ // (Setup: ilSetupLanguage, Init: LanguageLegacyInitialisationAdapter).
$implement[\ILIAS\Language\Language::class] = static fn() =>
$internal[\ilSetupLanguage::class];
$implement[\ILIAS\Language\Language::class] = static fn() =>
- $internal[\ilLanguage::class];
+ $internal[Language\LanguageLegacyInitialisationAdapter::class];
+
+ $provide[LanguageFileDirectoryManager::class] = static fn() =>
+ $internal[LanguageFileDirectoryManager::class];
+
+ $contribute[LanguageFileDirectory::class] = static fn() => new MainLanguageFileDirectory();
$contribute[\ILIAS\Setup\Agent::class] = static fn() =>
new \ilLanguageSetupAgent(
$pull[\ILIAS\Refinery\Factory::class],
- $internal[\ilSetupLanguage::class]
+ $internal[\ilSetupLanguage::class],
+ // Setup checks no permissions, hence no RBAC.
+ InstallLanguage::forSetup($internal[\ilSetupLanguage::class]),
+ // no plugin language files re-applied: the plugin objectives do that in the Setup
+ UpdateLanguage::forSetup($internal[\ilSetupLanguage::class])
);
- $internal[\ilSetupLanguage::class] = static fn() =>
- new \ilSetupLanguage("en");
-
- $internal[\ilLanguage::class] = static fn() =>
- new Language\LanguageLegacyInitialisationAdapter();
+ // Provided under the concrete class because Init.php pulls it, so none may become final
+ // (the GUI tests mock them).
+ $activities = [
+ InstallLanguage::class,
+ UpdateLanguage::class,
+ UninstallLanguage::class,
+ RemoveLocalLanguageChanges::class,
+ AddLanguageEntry::class,
+ SetLanguageDetectionEnabled::class,
+ SetLanguageTranslationEnabled::class,
+ ];
+ foreach ($activities as $activity) {
+ $provide[$activity] = static fn() => $internal[$activity];
+ $contribute[\ILIAS\Component\Activities\Activity::class] = static fn() => $internal[$activity];
+ }
$contribute[User\Settings\UserSettings::class] = fn() =>
new Language\UserSettings\Settings();
+ $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory(
+ $this,
+ 'lng'
+ );
}
}
diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md
index 9a60a158bb43..6c55894a6bb4 100755
--- a/components/ILIAS/Language/README.md
+++ b/components/ILIAS/Language/README.md
@@ -6,22 +6,505 @@ Language Service
# General Information
## Guidelines
-There are a couple of guidelines defined in the [language.md](../../docs/development/language.md) that have to be respected when adding language variables to the lang files of ILIAS or editing them.
+There are a couple of guidelines defined in the [language.md](../../../docs/development/language.md) that have to be respected when adding language variables to the lang files of ILIAS or editing them.
-## Using the Global Language Object
-The global language object can be retrieved from the dependency injection container by using/calling `$DIC['lng']` or
-`$DIC->language()`. This is an instance of class `ilLanguage` and provides methods to access these strings in the
-language of the user within the current authentication process. This is done by using the functions
- `loadLanguageModule()` and `txt()`.
+## Using the Language Service
+Component-revision code should depend on `ILIAS\Language\Language`. During the legacy bootstrap this interface is
+provided lazily, so early component construction does not access the language service before the legacy container has
+been initialised. After initialisation it delegates to the active `ilLanguage` runtime instance.
- $lng->loadLanguageModule("frm");
- $tpl->setVariable("TEXT", $lng->txt("frm_new_posting"));
+For components which have not yet been migrated, the active runtime language remains available through `$DIC['lng']`
+or `$DIC->language()`. These access paths are a temporary compatibility layer and must not be used for new code.
+Setup code has a separate language implementation, `ilSetupLanguage`, which is constructed without runtime user,
+session, or container state. It must not be used as the runtime language service.
-## Supported HTML Tags in Language Files
-Only a defined set of HTML tags are allowed to be used within the `text_content` of a language entry:
+ $language->loadLanguageModule("frm");
+ $tpl->setVariable("TEXT", $language->txt("frm_new_posting"));
-* All tags allowed by `getSecureTags` from `ilUtil`: `a`, `b`, `bdo`, `code`, `div`, `em`, `gap`, `i`, `img`, `li`, `ol`, `p`, `pre`, `strike`, `strong`, `sub`, `sup`, `u` and `ul`
-* In addition: `span` and `br`
+### Language Identifier Enums
+`txt()` (`ILIAS\Language\Language`) accepts, besides a string, a `LanguageIdentifier`:
-All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes`.
+ $language->txt(PollLanguageIdentifier::POLL_ANSWERS); // text of the current language
+ $language->txt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count
+ $language->txt('poll_answers'); // unchanged
+
+- `ILIAS\Language\LanguageIdentifier` is implemented by a string backed enum per module (`LanguageIdentifier`):
+ the case value is the identifier, `module()` the module. The enum is generated from the module's `.pot` with
+ [`tools/language-identifier-enum/`](tools/language-identifier-enum/README.md) (`--check` in CI), so a removed
+ identifier fails at the call site instead of showing `-identifier-`.
+- With a `LanguageIdentifier` the identifier is read from exactly its module - no `loadLanguageModule()` beforehand, no
+ dependency on which module was loaded last (no collision of identifiers that exist in several modules). A shipped
+ module is looked up directly in its build and overlay; of a plugin module the values for the current
+ language are read (cache of `lng_modules`, else `lng_modules`) and kept for the request, for the default language its
+ entry is read from `lng_data`. No side effect on string calls: the module is not loaded, so the order "the module
+ loaded last wins" stays as it is. Without a text in the current language the one of the default language is served,
+ otherwise `-identifier-`.
+- With a string and without `$n`, `txt()` behaves as before, without a fallback module (see below).
+- An empty identifier (as for a string, also `"0"`) gives `""`.
+- `$n` only selects a plural form of a plural message of a migrated module, see [Plural Forms](#plural-forms).
+- `txtlng()` and `exists()` take a string only.
+- Breaking change (announced for the Jour Fixe): implementations of `ILIAS\Language\Language` and subclasses of
+ `ilLanguage` that override `txt()` must accept `string|LanguageIdentifier $a_topic, ?int $n = null`. `ntxt()` is
+ gone, use `txt($a_topic, $n)`. The second parameter `$a_default_lang_fallback_mod` is gone: load the module with
+ `loadLanguageModule()` or pass a `LanguageIdentifier`. A string no longer falls back to the default language
+ (#13467); only a `LanguageIdentifier` does. Behaviour change: where callers passed a fallback module, a key missing
+ in the user's language now shows `-key-` instead of the text of the default language (`ilPluginLanguage` and the fuzzy marker of the
+ language editor keep the fallback).
+ `ilSetupLanguage` reads only the identifier of a `LanguageIdentifier` (`$n` without effect) - Setup needs English at most.
+
+## Installing and Managing Languages
+Which languages are installed/available (`ILIAS\Language\Setup\InstalledLanguageRepository`) and installing, flushing
+or registering a language (`ILIAS\Language\Setup\LanguageInstallationManager`) are separate, narrower services -
+following [the repository pattern](../../../docs/development/repository-pattern.md) - rather than part of
+`ilSetupLanguage` itself. Both are internal to this component; `ilSetupLanguage` delegates to them and remains the
+concrete `ILIAS\Language\Language` implementation used during Setup and the entry point for callers outside the
+component.
+
+
+## Language File Directories
+Where language files are looked up - the global `lang` directories, a component's own language
+files, and the Customizing overrides - is resolved through `ILIAS\Language\ComponentTranslation\*`
+(`src/ComponentTranslation/`): `LanguageFileDirectory` is the interface each source implements
+(`MainLanguageFileDirectory`, `ComponentLanguageFileDirectory`, `CustomizingLanguageFileDirectory`),
+and `LanguageFileDirectoryManager` aggregates all of them, contributed by other components via
+`$contribute[LanguageFileDirectory::class]` and gathered in `Language.php` via
+`$seek[LanguageFileDirectory::class]`. `InstalledLanguageDatabaseRepository` and
+`LanguageInstallationManager` both depend on this manager rather than hardcoding paths.
+
+## Translations Maintained in PO Files
+A module is shipped by its component as gettext files instead of lines in
+`lang/ilias_.lang`. All modules of the global files are migrated:
+`lang/ilias_.lang` only holds the header, the database serves plugin
+modules only. For such a module the shipped `.po` is
+the only source of its shipped values: its lines are removed from
+`lang/ilias_.lang` when it is migrated (`--remove-from-lang`, see
+[Migrating a Module](#migrating-a-module)); a rollback restores them from git
+(see [Rollback](#rollback)). Lines left there would be ignored when installing
+or updating a language.
+
+1. [Shipped Files](#shipped-files)
+2. [Build](#build)
+3. [Runtime](#runtime)
+4. [Plural Forms](#plural-forms)
+5. [Local Changes and Database](#local-changes-and-database)
+6. [Markup Policy](#markup-policy)
+7. [Language Administration](#language-administration)
+8. [Setup](#setup)
+9. [Plugins](#plugins)
+10. [Migrating a Module](#migrating-a-module)
+11. [Rollback](#rollback)
+
+### Shipped Files
+* The component contributes a `ComponentLanguageFileDirectory` with the module
+ as prefix (`$contribute[LanguageFileDirectory::class]` in `.php`)
+ and ships `.pot` and one `_.po` per language in its
+ `lang/` directory. A 4th constructor argument may name the files differently
+ (e.g. `'ilias_%s'` for `ilias_.po`; exactly one `%s`, no `/` or `..`).
+ Build artifacts and overlays are always named after the module.
+* Several modules of one component share its `lang/` directory and are told
+ apart by their file names only: `LanguageFileDirectoryManager` refuses two
+ directories that ship the same files. An `ilias_.lang` must not be put
+ there: it is read once per contributed directory, so its lines would end up
+ in every module of that folder.
+* `msgid` is the language key (`txt()` is unchanged). There is no `msgctxt`: an
+ entry without context belongs to the module of its directory, an entry with
+ the module as context is read as well, every other context (also
+ `msgctxt ""`) is ignored. Only UTF-8 files are accepted.
+* A `###` comment of a `.lang` file is a note: the reference language's note
+ is an extracted comment (`#.`, also in the `.pot`), a language's own note a
+ translator comment (`# `, kept by `msgmerge`), which wins. `#, fuzzy` marks
+ an entry that is not translated yet (it holds the English text); fuzzy
+ entries are served like all others, empty translations are not shipped
+ (`-identifier-`, as before).
+* The initial files are generated with
+ [`tools/po-migration/convert_module_to_po.php`](tools/po-migration/README.md),
+ later changes are made in the `.po`/`.pot` files directly. The module has no
+ `.lang` lines any more, so a new run of the tool fails ("Nothing was
+ written") and leaves the files unchanged.
+* A module counts as migrated for a language while its shipped `.po` exists
+ (Setup, administration) and the build contains it (runtime).
+
+### Build
+`php cli/setup.php build` (also run by `composer install` and
+`composer dump-autoload`, see `ShippedTranslationsBuild`) compiles every
+shipped `.po` into `artifacts/language//`:
+`messages/LC_MESSAGES/..mo`, `keys/.php`, a copy of the C
+library's `C.utf8` locale and a manifest (also the hashes of the compiled
+`.po` files, warnings and collisions). `artifacts/language/current.json` names
+the build that is served, its languages per module and every contributed
+module, nothing else, as every request reads it; it is switched only after the
+new build is complete and passed a real lookup through native gettext, and the
+previous build is kept for running requests.
+
+* **The build aborts** if the PHP extension `gettext` (a precondition of
+ `setup build`) or `C.utf8` is missing.
+* Not aborting, but reported on every build: values with markup that is not
+ allowed (compiled cleaned, see [Markup Policy](#markup-policy)), a `.po` that
+ cannot be compiled (e.g. merge conflict markers) and identifiers that two
+ migrated modules ship with different values (grouped by modules). A reused,
+ unchanged build lists only the `.po` files that cannot be compiled and counts
+ the rest; the details stay in its `manifest.json`. For a `.po` that cannot be
+ compiled the language keeps the catalog of the served (or previous) build,
+ if that build was made by the same compiler and its catalog is intact and
+ passes the markup policy; otherwise the module is not served for that
+ language (`-identifier-`, like a `.lang` line that cannot be read) until the
+ file is fixed; the runtime logs it when the module is used.
+* **Deployment requirement:** run `php cli/setup.php build` after every change
+ of a shipped `.po`. The runtime never compiles; a file that is not in the
+ build is not served.
+* Only Setup builds. The web server never writes into `artifacts/`.
+
+### Runtime
+`ilLanguage` serves a module shipped by a component only through a
+`TranslationRepository`, by default `TranslationGettextRepository` (native
+gettext, see `NativeGettext`): the build with the overlay of the
+local changes on top, never from the database - also in Setup and CLI, where
+the client data directory of the overlay is taken from `ilias.ini.php` until
+`CLIENT_DATA_DIR` is defined. A module counts as shipped if `current.json`
+lists it as contributed, without a build if a component ships its
+`lang/.pot` (the build warns about a contribution without one); this
+needs no component graph.
+Only other modules (plugins) are read from `lng_data`/`lng_modules`.
+
+* Without a build, for a language the build lacks, or for a language that is
+ not installed a shipped module has no texts (`-identifier-`); a missing
+ build or a shipped `.po` that is not built is reported.
+* Requirements: the PHP extension `gettext` and the locale `C.utf8` are
+ mandatory for CLI and web server, with the same C library for both (glibc;
+ musl ignores `LOCPATH`). The build checks this in the CLI process only.
+ Without them every text of a shipped module is shown as `-identifier-`; the
+ problem is logged and shown in the language administration. With a
+ thread-safe PHP (ZTS) the locale applies to every thread of the process.
+* Behaviour change: an identifier that the migrated module loaded last does not
+ translate gives `-identifier-`, not the value of a module loaded before.
+* `txt()` with a `LanguageIdentifier` reads from exactly its module, see
+ [above](#language-identifier-enums).
+* Another `TranslationRepository` (e.g. in unit tests) is handed to
+ `new ilLanguage($lang_key, $translations)`. The static methods
+ (`_lookupEntry()`, the invalidation after a write) always use
+ `TranslationGettextRepository`. Its state is static, so every instance
+ shares it, like native gettext its domains. Build status and problems stay
+ in `MigratedTranslations`.
+* Apart from these lookups, `.po`/`.mo` files are read and written with the
+ library `gettext/gettext`, only through the adapter in
+ `src/ComponentTranslation/Catalog/` (`TranslationCatalog`,
+ `TranslationEntry`). No other class imports from `Gettext\`, and the
+ library's `Scanner` is not used.
+
+### Plural Forms
+`txt($a_topic, $n)` returns the form of a plural message (`msgid_plural`,
+`msgstr[n]`) that the `Plural-Forms` rule of the language selects for `$n`.
+The quantity is not inserted; callers do that, e.g. with `sprintf()`. In every
+other case (module not migrated, plugin, no plural message, empty form) it
+returns exactly what `txt()` without `$n` returns in that language, never the
+form of another language.
+
+* `txt()` without `$n` returns the default form of a plural message: the last form
+ (CLDR "other"), or the last non-empty one before it. The database holds this
+ form only; all forms exist only in the `.po` files, the build and the
+ overlay.
+* The rules are taken from CLDR (`tools/po-migration/plurals.json`). They are
+ checked when compiling (`PluralForms`, no `eval`); a missing or invalid
+ header falls back to `nplurals=2; plural=(n != 1);` with a warning.
+* The administration shows each form as a row of its own, ` []`
+ (e.g. `poll_population [0]`). Keys containing ` [` cannot be migrated.
+* If the rule of a language changes (e.g. CLDR 49: `vi` 1 → 2), local form
+ values are taken over by meaning, not by index: a new form gets the local
+ value of the old form that served the same quantities (n = 0…200 and some
+ large values). If it covers several old forms, it gets their value if they
+ agree, and the new default form gets the old default form; otherwise it
+ shows the shipped value (logged when the overlay is written). The overlay
+ keeps its old rule until the next write (e.g. `setup update`): until then
+ the runtime serves the local plural messages with the old rule (consistent
+ in itself, by meaning), `setup build` alone does not change that; the
+ administration and every write already map it to the new rule. Form keys in imported files (` []`) always mean the
+ forms of the current rule.
+
+### Local Changes and Database
+Shipped files are never written at runtime, except by "merge" (see
+[Language Administration](#language-administration)). Each installation keeps
+only its local changes in the "overlay" below the client data directory,
+`/lang///`: the `.po` with the bookkeeping
+(`# original`, `# local_change`, `# remark`), `lock`, `current` and the
+compiled revisions `r-/` that `current` names.
+
+* The overlay holds the delta only: entries whose value differs from the
+ shipped one, keys that are not shipped (customizing file, "add new
+ variable") and remarks. Setting an entry back to the shipped value, or to an
+ empty value, removes it; an empty overlay is deleted. Installing a language
+ without local changes writes no overlay (only the record of the
+ [Setup](#setup) that `lng_data` was taken over).
+* `lng_data`/`lng_modules` are still written on every path (with the cleaned
+ shipped values and the default form of plural messages) as fallback for a
+ rollback. They must not be dropped as long as code outside of this component
+ reads `lng_data` directly (`ilNotificationDatabaseHandler`,
+ `ilDclStandardField`).
+* Writes of a module's overlay and database rows are serialized per module and
+ language by an exclusive lock on `lock` in the overlay directory. Setup and
+ "remove local changes" lock every module with an overlay for the whole run.
+ Known limitation: `lng_data` is written in one batch, so a concurrent edit of
+ a module without overlay can lose its database row (the overlay, which is
+ served, keeps the edit).
+* Symbolic links below `/lang` are not followed. The checks
+ work on path names, so a link swapped in between check and file operation is
+ still followed (requires write access as web server user).
+* **Setup MUST be run as the web server user** (the owner of the client data
+ directory), since Setup and the web server both write the overlay. Setup does
+ not change ownership: it warns, naming the directories and the `chown`
+ command. The administration shows a warning whenever an overlay could not be
+ written or removed.
+* An overlay that cannot be read (e.g. edited by hand) is reported: at runtime
+ the module serves its shipped values, Setup leaves the module unchanged
+ (nothing written, nothing taken from `lng_data`) and saving in the
+ administration is refused for it. Its local changes are only served again
+ once the overlay is repaired. Until then the rollback copy of the module in
+ `lng_data`/`lng_modules` is incomplete: Setup's flush removed its rows that
+ are not local changes, `lng_modules` keeps its former row.
+* Uninstalling a language removes its overlays.
+
+### Markup Policy
+Which HTML a language value may contain is decided in one place,
+`ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy`, for every
+module, migrated or not. It uses PHP's HTML5 parser (no regular expressions);
+an allowed value stays byte-identical.
+
+* Tags: `a`, `b`, `bdo`, `br`, `code`, `div`, `em`, `gap`, `h3`, `i`, `img`,
+ `li`, `ol`, `p`, `pre`, `s`, `small`, `span`, `strike`, `strong`, `sub`,
+ `sup`, `u`, `ul`.
+* Attributes (names case-insensitive): `a[href|target|rel|title]`,
+ `img[src|alt]`, `span[class|style]`, `p[align]`, `div[align]`; no others.
+ `href`/`src` must be relative or use `http`/`https` (`href` also `mailto`),
+ checked on the decoded value. An attribute value must not contain `<`.
+* `style` (on `span` only): the properties `background-color`, `color`,
+ `font-style`, `font-weight`, `text-align` and `text-decoration`; no functions
+ except `rgb`, `rgba`, `hsl`, `hsla`, no CSS escapes or comments.
+* Not allowed either: comments, a value ending inside an unfinished tag, and
+ unbalanced markup - an element left open (unless HTML5 allows to omit its end
+ tag, e.g. `li`, `p`), an end tag closing an element around the value, and
+ the end tag of any tag that is not allowed (``). Known gap: a stray
+ `
` or `` is not detected.
+* A value containing `<` that is longer than 16 KiB is not parsed and never
+ allowed.
+* Text that is no tag to an HTML parser (`a <= b`, `<<`) is allowed.
+* Values without `<` are not checked at all: output in attributes or
+ JavaScript must be escaped by the caller.
+
+Where the rules apply:
+* Build (shipped `.po`) and plugin `.po`: offending tags, attributes and
+ comments are removed (their text stays) and reported; nothing aborts.
+* Local write paths reject such values instead, checking only values that
+ differ from the current and the shipped one: the edit table and the import
+ of an uploaded file (nothing is saved, every affected key is named with the
+ offending markup), "add new variable" (the whole request is rejected), the
+ customizing file `lang/customizing/ilias_.lang.local` (the affected
+ entries are left out and named; the language is installed regardless).
+* `setup update` reports local changes that break the rules, without changing
+ them.
+* Plugin `.lang` files and the shipped `lang/ilias_.lang` files are not
+ changed; violations of plugin `.lang` files are logged.
+
+### Language Administration
+* The edit table compares with the shipped `.po`. A rejected save shows the
+ table again with the input. The remark field of a module maintained in PO
+ files shows the note of the shipped entry as long as there is no local
+ remark; saving it unchanged writes nothing. Fuzzy entries are marked
+ "Not translated" (`lng_not_translated`) in the comment of the default value.
+* Remarks of migrated modules are stored in the overlay (and `lng_data`), one
+ per key, also for plural messages.
+* Import of a language file: for a migrated module, imported values that
+ differ from the shipped ones become local changes in the overlay, in every
+ mode. A line without `###` comment keeps the stored remark. "delete" first
+ deletes the language's database rows and then writes every module anew; a
+ remark that is not in the file is removed from the overlay as well. Markup is
+ checked before anything is deleted. So are identifiers that exist in another
+ module or in another spelling (as for "add new variable", an entry the module
+ already holds stays importable): an uploaded file is rejected as a whole, the
+ maintenance action "load" leaves such entries out; both name them
+ (`lng_identifier_not_unique`). Installing, updating and "install local" leave
+ such entries of the customizing file out as well and name them (Activity
+ output `duplicate_customizing_entries`, setup output). The check reads
+ `lng_data` of all languages: after a key moved to another module, `setup
+ update` may report a customizing line as a duplicate as long as a language
+ updated later in the same run still holds the old row. The next run applies it.
+* "Remove local changes" and the maintenance action "clear" are the same
+ activity (`RemoveLocalLanguageChanges`): values, locally added keys and
+ remarks are removed from database and overlay, the shipped state is
+ re-installed and the plugin languages are re-imported.
+* "save_dist" also copies the shipped `.po` of each migrated module into
+ `/lang_data/`; the filter "conflicts" compares with these
+ copies.
+* "merge" (translation mode only, checked on the server) writes the global
+ `.lang` file for modules that are not migrated, and the local changes of
+ migrated modules into their shipped `.po` (new keys also into the `.pot`;
+ remarks become an additional `# ` line); a read-only global `.lang` file does
+ not stop the merge into the `.po` and is only reported if unmigrated modules
+ have local changes. The previous files are copied to
+ `lang/customizing/` (ignored by git), the written files belong to the web
+ server user. Values with markup that is not allowed are not taken over. The
+ overlay keeps serving the merged values until `php cli/setup.php build` and
+ then `php cli/setup.php update` ran. Afterwards remove the lines of the
+ merged keys from `lang/customizing/ilias_.lang.local`; otherwise the
+ next update takes them as local changes again (and a remark appears twice).
+
+### Setup
+`setup update` (and "update" in the administration) reconciles each entry of a
+migrated module three-way: L = the local value (overlay or customizing file),
+O = the shipped value recorded in the overlay (`original`), S = the new shipped
+value.
+
+The local changes in `lng_data` (e.g. of an upgrade from ILIAS 10/11) are taken
+over into the overlay once, while a module has no overlay for the language yet.
+Afterwards (recorded in `/lang/taken-over..json`, also
+for a module without any local change) and whenever an overlay exists, the
+overlay is the only source of local changes and remarks: `lng_data` is no longer
+read for the module. Uninstalling a language forgets the record. Plugin modules
+are read from `lng_data` as before.
+
+| Case | Result |
+|---|---|
+| no L | S |
+| L == S | S, no longer a local change |
+| L == O (only the shipped value changed) | S |
+| otherwise | L stays, `original` becomes S |
+
+Values from the customizing file always stay local. A key removed from the
+shipped `.po` is removed unless it carries a real local change.
+
+* `setup install`/`update` first run the build (`ShippedLanguageFilesCompiledObjective`
+ is a precondition of installing and updating the languages), so the Setup
+ serves the shipped modules from a current build. They therefore need the PHP
+ extension `gettext` and a writable `artifacts/language/` (including
+ `build.lock`), like `setup build`.
+* A language with an invalid language file (e.g. a shipped `.po` that cannot be
+ parsed) is skipped and reported; the run goes on.
+* **`setup update` aborts with `LanguageDataNotSavedException`** if a module
+ array written to `lng_modules` cannot be read back (almost always a wrong
+ collation of `lng_data`/`lng_modules`).
+* Warnings are printed for skipped customizing entries, local changes with
+ markup that is not allowed, overlay directories that are not writable, a
+ record of taken over local changes that cannot be written (taken over again
+ by the next update), overlays or records of modules that are not shipped, a
+ Setup not running as owner of the client data directory, and a missing
+ client data directory (the overlay is then written by the next update; until
+ then migrated modules show their shipped values without local changes).
+
+### Plugins
+Plugins cannot contribute a `LanguageFileDirectory` yet. A plugin may ship
+`lang/ilias_.po` instead of `lang/ilias_.lang` (the `.po` wins if
+both exist): `msgid` is the key without the plugin prefix, entries with a
+`msgctxt` are ignored and logged. The values go into the database as before
+(no build, no overlay), comments are not stored, plural messages are
+stored with their default form (`$n` has no effect for plugins). Values
+of a plugin `.po` with markup that is not allowed are cleaned and logged;
+values of a plugin `.lang` are only logged and taken over unchanged.
+
+### Migrating a Module
+1. Contribute the module's `ComponentLanguageFileDirectory` in the
+ component's `.php` (`$contribute[LanguageFileDirectory::class]`);
+ `--remove-from-lang` refuses to run without it.
+2. Generate the `.pot`/`.po` files into the component's `lang/` directory and
+ remove the module's lines from `lang/ilias_.lang` with
+ [`convert_module_to_po.php --remove-from-lang`](tools/po-migration/README.md),
+ then compare the placeholders with the reference language.
+3. Run `composer dump-autoload` (or `php cli/setup.php build`) and
+ `php cli/setup.php update`.
+
+### Rollback
+The database tables are written on every path, so a rollback removes code or
+files, it never writes data back.
+
+* Per module: revert the migration with git (restores the module's `.lang`
+ lines, removes the contribution and the `.po`/`.pot`, e.g. `git revert` of
+ the migration commit), run `composer dump-autoload` (rebuilds the component
+ graph and the build) and `php cli/setup.php update`. Local changes of plural
+ forms other than the default form are lost, values merged into the `.po` must
+ be added to the `.lang` files. The module's overlay and its entry in
+ `taken-over..json` are kept (Setup reports them on every update):
+ remove both before migrating the module again, otherwise they win over the
+ changes made in `lng_data` in the meantime. Setup only reports this if at
+ least one `setup update` runs between the revert and the new migration,
+ otherwise these changes are lost without notice.
+* Per language: not by removing its shipped `.po` - the module then shows
+ `-identifier-` for that language, never its database rows; revert the module
+ instead.
+* Shipped modules never fall back to the database: `ilLanguage` reads
+ `lng_data`/`lng_modules` only for modules that are not shipped (see
+ [Runtime](#runtime)). A fallback for a module or language without build needs
+ a code change of `ilLanguage::migratedKeysOf()`.
+* `artifacts/language/` can be deleted and rebuilt with
+ `php cli/setup.php build`; until then migrated modules show `-identifier-`.
+
+## User Settings Contribution
+This component contributes a personal "language" setting to the user settings framework
+(`ILIAS\Language\UserSettings\Settings`, wired in `Language.php` via
+`$contribute[User\Settings\UserSettings::class]`).
+
+## Activities
+This component provides seven [Activities](../Component/src/Activities/README.md)
+(`ILIAS\Language\Activities\*`), each wired into `Language.php` (`$internal`/`$provide`/
+`$contribute`) and used by this component's own GUI classes via `maybePerformAs()`. All seven
+are `Command`s (they change installation/configuration state; none of them queries data without
+side effects). See each class's own `getDescription()` for its authoritative, up-to-date
+description rather than a copy here, which would drift out of sync:
+
+* **InstallLanguage** (`src/Activities/InstallLanguage.php`) - installs/re-applies one or more
+ languages, depending on the chosen mode. Mode `install` (`MODE_INSTALL`) fully installs a
+ language that is not installed yet and is a no-op for an installed one. Mode `install_local`
+ (`MODE_INSTALL_LOCAL`) only applies to already installed languages: it re-applies just the
+ customizing/local file (`ilias_.lang.local`) on top of the stored data; not installed
+ languages are skipped (reported as `not_installed_language_keys`). This is intended.
+* **UpdateLanguage** (`src/Activities/UpdateLanguage.php`) - refreshes one or more already
+ installed languages from the current language files. The entries of a module maintained in PO
+ files are reconciled three-way with its shipped `.po`, see [Setup](#setup) above.
+* **UninstallLanguage** (`src/Activities/UninstallLanguage.php`) - uninstalls one or more already
+ installed languages.
+* **RemoveLocalLanguageChanges** (`src/Activities/RemoveLocalLanguageChanges.php`) - removes all
+ local changes of one or more already installed languages and reinstalls them from the
+ global/component language files.
+* **AddLanguageEntry** (`src/Activities/AddLanguageEntry.php`) - adds one new "adjust language
+ variables" entry to every currently installed language for which a value was given.
+* **SetLanguageDetectionEnabled** (`src/Activities/SetLanguageDetectionEnabled.php`) - enables or
+ disables the system-wide automatic language detection from the browser's Accept-Language
+ header.
+* **SetLanguageTranslationEnabled** (`src/Activities/SetLanguageTranslationEnabled.php`) - enables
+ or disables the "page translation" feature for one specific language.
+
+### Known, accepted deviations from the Activity contract
+
+* **AddLanguageEntry::perform()** additionally accepts an optional `usr_id` (int) key in its
+ `$parameters`, even though `getInputDescription()`'s `FormInput` never produces one - it is
+ supplied out-of-band by `maybePerformAs()` (never spoofable via form data) and recorded as the
+ author of the local change made to every written entry. A caller that omits it (e.g. a generic
+ caller following the plain `getInputDescription()` -> `withInput()` -> `getContent()` ->
+ `perform()` contract) is not rejected; the entries are simply written without an attributed
+ author.
+* **AddLanguageEntry::getInputDescription()** builds per-language field labels via
+ `$this->lng->txt('meta_l_' . $lang_key)`, which requires the caller to have already called
+ `loadLanguageModule('meta')` beforehand. This component's own GUI does so already (see
+ `classes/class.ilObjLanguageExtGUI.php`'s constructor). A caller that skips this gets the raw,
+ untranslated placeholder (e.g. `-meta_l_de-`) as the label instead of a crash. `getInputDescription()`
+ deliberately does not load the module itself, since `loadLanguageModule()` merges the module's
+ keys, unnamespaced, into the shared `$this->lng` state - doing so implicitly here could clobber
+ keys for an unrelated module if a future generic caller iterates over several Activities sharing
+ one `Language`/`ilLanguage` instance.
+* **AddLanguageEntry::getInputDescription()** resolves the current set of installed languages from
+ `InstalledLanguageRepository::getInstalledLanguages()` on every call, rather than being a pure,
+ static description of the input shape. Within one `maybePerformAs()` call, `getInputDescription()`
+ and `perform()` can therefore observe different snapshots if a language is installed/uninstalled
+ concurrently between the two calls: a newly installed optional language simply ends up in
+ `skipped_empty_language_keys`; a newly installed `de`/`en` makes `perform()` reject the whole
+ request (fail-closed, since no value could have been submitted for it); a language uninstalled
+ in the meantime has its submitted value silently dropped rather than written or reported. No
+ case crashes or writes partial data.
+
+## Further Reading
+* [use-language-object.md](use-language-object.md) and [use-language-logging.md](use-language-logging.md)
+ document the older, still widely used `$lng`/`ilLanguage` access pattern. They predate the
+ `ILIAS\Language\Language` interface and the Activities described above and have not been updated
+ to reference them; prefer the "Using the Language Service" section above for new,
+ component-revision code.
+* [`tests/LanguageComponentGraphTest.php`](tests/LanguageComponentGraphTest.php) is a contract test
+ for this component's `$define`/`$implement`/`$use`/`$contribute`/`$seek`/`$provide`/`$pull`/`$internal`
+ wiring in `Language.php` and is a good place to check the current, authoritative wiring behaviour
+ (e.g. singleton guarantees) rather than relying on prose descriptions here.
diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md
index 1e394d3d2505..2033ae544032 100755
--- a/components/ILIAS/Language/ROADMAP.md
+++ b/components/ILIAS/Language/ROADMAP.md
@@ -4,17 +4,40 @@
* Accelerated language update (no more need to introduce background tasks)
* Removed language installation in config (only English is installed by Setup)
-* Substituted LegacyUI Button by KS Button
+* Substituted LegacyUI Button by KS Button in the folder GUI's toolbar (`ilObjLanguageFolderGUI`);
+ the form action buttons in `ilObjLanguageExtGUI` (`addCommandButton()`) are still legacy `ilPropertyFormGUI` buttons
+* Migrated the component to the Component Revision's dependency graph
+ (`$define`/`$implement`/`$use`/`$contribute`/`$seek`/`$provide`/`$pull`/`$internal` in `Language.php`)
+ and introduced the Activities pattern: seven `ILIAS\Language\Activities\*` classes now hold this
+ component's install/update/uninstall/detection/translation-toggle/entry-editing logic, used by the
+ GUI classes via `maybePerformAs()` - see the README's "Activities" section
+* Extracted language-installation state access into `InstalledLanguageRepository` and
+ `LanguageInstallationManager` (repository pattern), and language-file-directory resolution into
+ `ILIAS\Language\ComponentTranslation\*` - see the README
+* Plural forms for modules maintained in PO files: `txt($a_topic, $n)`, `Plural-Forms` evaluated by
+ `ILIAS\Language\ComponentTranslation\PluralForms` (pilot modules `tos` and `poll`, see
+ [Translations Maintained in PO Files](README.md#translations-maintained-in-po-files))
## Short Term
* Fixing PHP 8.2 issues
* Analysing use of language variables on test9
* GitHook for preventing duplicate use of same variable_ID in language files
+* Migrate the remaining `addCommandButton()` form buttons in `ilObjLanguageExtGUI` to KS Button
+* Take the system settings of `SetLanguageDetectionEnabled`/`SetLanguageTranslationEnabled` from the
+ dependency graph as soon as Administration offers them there; until then `LanguageSettingsLegacyProxy`
+ reads them from the legacy container
+* Let the GUI classes get the Activities by dependency injection once ilCtrl allows it, and drop the
+ Activities and the `LanguageFileDirectoryManager` from the legacy `$DIC` (`AllModernComponents`)
+* Build `lng` in the dependency graph instead of `ilInitialisation::initLanguage()`
+* Replace hand-made singular/plural key pairs by plural messages and `txt($a_topic, $n)` together
+ with the owning components (first proposal: `Poll`)
## Mid Term
-* Move LegacyUI Table2GUI to KS Data Table
+* Move LegacyUI Table2GUI to KS Data Table - `ilLanguageFolderTable` and `ilLanguageStatisticsTable`
+ already implement the KS `DataRetrieval` table; only `ilLanguageExtTableGUI` (still `extends ilTable2GUI`,
+ used by `ilObjLanguageExtGUI::getViewTable()`) remains to be migrated
* Remove unused language variables from language files
* Improving export and import of customised language files
* Improving online translation tool
diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php
index c97bc0bd8b45..da63baed6d60 100755
--- a/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php
+++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php
@@ -61,11 +61,8 @@ protected function collectFrom(Setup\Environment $environment, Setup\Metrics\Sto
if (!($db instanceof \ilDBInterface)) {
return;
}
- $this->il_setup_language->setDbHandler($db);
- // TODO: Remove this once ilSetupLanguage (or a successor) supports proper
- // DI for all methods.
- $GLOBALS["ilDB"] = $db;
+ $this->il_setup_language->setDbHandler($db);
$installed_languages = [];
$local_languages = $this->il_setup_language->getLocalLanguages();
diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php
index fbda65bbce47..a9be7f366ab5 100755
--- a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php
+++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php
@@ -21,6 +21,9 @@
use ILIAS\Setup;
use ILIAS\Refinery;
use ILIAS\UI;
+use ILIAS\Language\Activities\InstallLanguage;
+use ILIAS\Language\Activities\UpdateLanguage;
+use ILIAS\Language\Setup\ShippedLanguageFilesCompiledObjective;
class ilLanguageSetupAgent implements Setup\Agent
{
@@ -28,13 +31,19 @@ class ilLanguageSetupAgent implements Setup\Agent
protected Refinery\Factory $refinery;
protected \ilSetupLanguage $il_setup_language;
+ protected InstallLanguage $install_language;
+ protected UpdateLanguage $update_language;
public function __construct(
Refinery\Factory $refinery,
- \ilSetupLanguage $il_setup_language
+ \ilSetupLanguage $il_setup_language,
+ InstallLanguage $install_language,
+ UpdateLanguage $update_language
) {
$this->refinery = $refinery;
$this->il_setup_language = $il_setup_language;
+ $this->install_language = $install_language;
+ $this->update_language = $update_language;
}
/**
@@ -61,7 +70,11 @@ public function getInstallObjective(?Setup\Config $config = null): Setup\Objecti
return new Setup\ObjectiveCollection(
"Complete objectives from components/ILIAS/Language",
false,
- new ilLanguagesInstalledAndUpdatedObjective($this->il_setup_language),
+ new ilLanguagesInstalledAndUpdatedObjective(
+ $this->il_setup_language,
+ $this->install_language,
+ $this->update_language
+ ),
new ilDefaultLanguageSetObjective()
);
}
@@ -74,16 +87,23 @@ public function getUpdateObjective(?Setup\Config $config = null): Setup\Objectiv
return new Setup\ObjectiveCollection(
"Complete objectives from components/ILIAS/Language",
false,
- new ilLanguagesInstalledAndUpdatedObjective($this->il_setup_language),
+ new ilLanguagesInstalledAndUpdatedObjective(
+ $this->il_setup_language,
+ $this->install_language,
+ $this->update_language
+ ),
);
}
/**
- * @inheritdoc
+ * The directories come from the component graph, via the LanguageFileDirectoryManager of ilSetupLanguage.
*/
public function getBuildObjective(): Setup\Objective
{
- return new Setup\Objective\NullObjective();
+ return new ShippedLanguageFilesCompiledObjective(
+ $this->il_setup_language->getLanguageFileDirectoryManager(),
+ dirname(__DIR__, 5)
+ );
}
/**
diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php
index f85796340a43..a424eaf6aa86 100755
--- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php
+++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php
@@ -19,24 +19,57 @@
declare(strict_types=1);
use ILIAS\Setup;
+use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync;
+use ILIAS\Language\ComponentTranslation\PlainLogText;
+use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy;
+use ILIAS\Language\Activities\InstallLanguage;
+use ILIAS\Language\Activities\UpdateLanguage;
+use ILIAS\Language\Setup\ShippedLanguageFilesCompiledObjective;
class ilLanguagesInstalledAndUpdatedObjective extends ilLanguageObjective
{
protected \ilSetupLanguage $il_setup_language;
+ protected InstallLanguage $install_language;
+ protected UpdateLanguage $update_language;
+ /** @var list|null */
+ private ?array $install_languages = null;
+ /**
+ * @param InstallLanguage|null $install_language null only for callers constructed deep inside the Setup tree
+ * (plugin objectives); a Setup-only instance is then built here instead of reading $GLOBALS['DIC']
+ * @param UpdateLanguage|null $update_language see $install_language
+ */
public function __construct(
- \ilSetupLanguage $il_setup_language
+ \ilSetupLanguage $il_setup_language,
+ ?InstallLanguage $install_language = null,
+ ?UpdateLanguage $update_language = null
) {
- parent::__construct();
$this->il_setup_language = $il_setup_language;
+ $this->install_language = $install_language ?? InstallLanguage::forSetup($il_setup_language);
+ $this->update_language = $update_language ?? UpdateLanguage::forSetup($il_setup_language);
}
/**
- * @inheritDoc
+ * The directory configuration is part of the hash: with a class-only hash Setup could keep the
+ * default-directory instance, which refreshes languages without the component-contributed modules.
*/
public function getHash(): string
{
- return hash("sha256", self::class);
+ $directories = [];
+ foreach ($this->il_setup_language->getLanguageFileDirectoryManager()->getAllDirectories() as $directory) {
+ $directories[] = implode('|', [
+ $directory::class,
+ $directory->getPrefix(),
+ $directory->getPath(),
+ $directory->getSuffix(),
+ ]);
+ }
+
+ return hash("sha256", implode("\n", [
+ self::class,
+ $this->il_setup_language->usesDefaultLanguageFileDirectories() ? 'default-directories' : 'configured-directories',
+ ...$directories,
+ ]));
}
/**
@@ -44,15 +77,7 @@ public function getHash(): string
*/
protected function getInstallLanguages(): array
{
- return $this->il_setup_language->getInstalledLanguages() ?: ['en'];
- }
-
- /**
- * Return installed local languages
- */
- protected function getInstallLocalLanguages(): array
- {
- return $this->il_setup_language->getInstalledLocalLanguages();
+ return $this->install_languages ??= $this->il_setup_language->getInstalledLanguages() ?: ['en'];
}
/**
@@ -60,7 +85,11 @@ protected function getInstallLocalLanguages(): array
*/
public function getLabel(): string
{
- return "Install/Update languages " . implode(", ", $this->getInstallLanguages());
+ // No database access here: the Setup CLI calls this before achieve().
+ if ($this->install_languages === null) {
+ return "Install/Update languages";
+ }
+ return "Install/Update languages " . implode(", ", $this->install_languages);
}
/**
@@ -76,9 +105,16 @@ public function isNotable(): bool
*/
public function getPreconditions(Setup\Environment $environment): array
{
- return [
- new ilDatabaseInitializedObjective()
- ];
+ $preconditions = [new ilDatabaseInitializedObjective()];
+ // with the default directories the build would serve no module at all
+ if (!$this->il_setup_language->usesDefaultLanguageFileDirectories()) {
+ $preconditions[] = new ShippedLanguageFilesCompiledObjective(
+ $this->il_setup_language->getLanguageFileDirectoryManager(),
+ dirname(__DIR__, 5)
+ );
+ }
+
+ return $preconditions;
}
/**
@@ -86,22 +122,298 @@ public function getPreconditions(Setup\Environment $environment): array
*/
public function achieve(Setup\Environment $environment): Setup\Environment
{
- $db = $environment->getResource(Setup\Environment::RESOURCE_DATABASE);
+ // Must come first: getInstallLanguages() reads from the database too.
+ $this->useSetupDatabase($environment);
+ $this->useSetupClientDataDir($environment);
+
+ $language_keys = [];
+ $invalid_language_keys = [];
+ foreach ($this->getInstallLanguages() as $language_key) {
+ if ($this->il_setup_language->checkLanguageForInstallation($language_key)) {
+ $language_keys[] = $language_key;
+ } else {
+ $invalid_language_keys[] = $language_key;
+ }
+ }
+ if ($invalid_language_keys !== []) {
+ // an invalid language file only skips that language, the Setup run goes on
+ $this->inform(
+ $environment,
+ 'Skipped languages with invalid language files (left unchanged): '
+ . implode(', ', $invalid_language_keys)
+ );
+ }
+
+ $this->informAboutMissingClientDataDirectory($environment, $language_keys);
+ $this->informAboutUnwritableOverlayDirectories($environment, $language_keys);
+ $this->informAboutUnwrittenOverlays($environment, $this->installLanguages($language_keys));
+ $this->informAboutSkippedInvalidMarkupEntries($environment);
+ $this->informAboutSkippedDuplicateEntries($environment);
+ foreach ($this->il_setup_language->getSkippedUnreadableOverlays() as $language_key => $modules) {
+ $this->inform($environment, PlainLogText::of(
+ 'WARNING: The overlay (local changes) of language ' . $language_key . ' cannot be read for the modules '
+ . implode(', ', $modules) . ': they were left unchanged, their local changes are only served again '
+ . 'once the overlay below /lang is repaired. Until then their rollback copy in '
+ . 'lng_data/lng_modules is incomplete.'
+ ));
+ }
+ $this->informAboutOverlaysOfUnshippedModules($environment);
+ $this->informAboutUnrecordedTakenOverModules($environment);
+ $this->informAboutInvalidMarkupInLocalChanges($environment, $language_keys);
+
+ return $environment;
+ }
+
+ /**
+ * Customizing entries with disallowed markup are not applied; the languages are installed regardless.
+ */
+ protected function informAboutSkippedInvalidMarkupEntries(Setup\Environment $environment): void
+ {
+ foreach ($this->il_setup_language->getSkippedInvalidMarkupEntries() as $language_key => $entries) {
+ $this->inform(
+ $environment,
+ PlainLogText::of(
+ 'WARNING: Customizing entries of language ' . $language_key . ' were not applied, their '
+ . 'value contains HTML that is not allowed in language texts: '
+ . PlainLogText::keyList(array_map('strval', array_keys($entries)))
+ )
+ );
+ }
+ }
+
+ /** A warning only: the languages are installed regardless. */
+ protected function informAboutSkippedDuplicateEntries(Setup\Environment $environment): void
+ {
+ foreach ($this->il_setup_language->getSkippedDuplicateEntries() as $language_key => $entries) {
+ $this->inform(
+ $environment,
+ PlainLogText::of(
+ 'WARNING: Customizing entries of language ' . $language_key . ' were not applied, their '
+ . 'identifier already exists in another module or in another spelling: '
+ . PlainLogText::keyList(array_map('strval', array_keys($entries)))
+ )
+ );
+ }
+ }
+
+ protected function informAboutOverlaysOfUnshippedModules(Setup\Environment $environment): void
+ {
+ foreach ($this->il_setup_language->getOverlaysOfUnshippedModules() as $language_key => $modules) {
+ $this->inform($environment, PlainLogText::of(
+ 'WARNING: The modules ' . implode(', ', $modules) . ' have no shipped .po for language '
+ . $language_key . ', but an overlay below /lang or an entry in /lang/taken-over.' . $language_key . '.json. Remove the overlay directory /lang//' . $language_key . ' and the entry before the module is shipped again (or '
+ . 'if it no longer exists) - otherwise they win over the local changes in lng_data.'
+ ));
+ }
+ }
+
+ protected function informAboutUnrecordedTakenOverModules(Setup\Environment $environment): void
+ {
+ foreach ($this->il_setup_language->getUnrecordedTakenOverModules() as $language_key => $modules) {
+ $this->inform($environment, PlainLogText::of(
+ 'WARNING: Could not record in /lang/taken-over.' . $language_key . '.json that the '
+ . 'local changes of language ' . $language_key . ' in lng_data were taken over for the modules '
+ . implode(', ', $modules) . ' (see the PHP error log for details). They are taken over again by the '
+ . 'next "setup update" - /lang (including .locks and the record file) must be '
+ . 'writable for the user running Setup.'
+ ));
+ }
+ }
+
+ /**
+ * Local changes written before the markup check existed are only reported, never changed:
+ * that is the administrator's decision.
+ *
+ * @param list $language_keys
+ */
+ protected function informAboutInvalidMarkupInLocalChanges(Setup\Environment $environment, array $language_keys): void
+ {
+ $policy = new TranslationMarkupPolicy();
+ $manager = $this->il_setup_language->getLanguageFileDirectoryManager();
+ $client_data_dir = $this->il_setup_language->getClientDataDir();
+ foreach ($language_keys as $language_key) {
+ $values = [];
+ try {
+ // a module with an overlay, or whose lng_data was taken over, is checked in its overlay only
+ $overlay_only = array_fill_keys(MigratedLanguageFileSync::modulesWithLocalChangesTakenOver($client_data_dir, $language_key), true);
+ foreach ($manager->getDirectories() as $directory) {
+ $module = $directory->getPrefix();
+ if (MigratedLanguageFileSync::hasOverlay($manager, $language_key, $module, $client_data_dir)) {
+ $overlay_only[$module] = true;
+ }
+ foreach (MigratedLanguageFileSync::loadLocalChanges($manager, $language_key, $module, $client_data_dir) ?? [] as $identifier => $entry) {
+ $values[$module . '#:#' . $identifier] = $entry['value'];
+ }
+ }
+ foreach ($this->il_setup_language->getLocalChanges($language_key) as $module => $entries) {
+ if (isset($overlay_only[$module])) {
+ continue;
+ }
+ foreach ($entries as $identifier => $value) {
+ $values[$module . '#:#' . $identifier] ??= (string) $value;
+ }
+ }
+ } catch (\Throwable $t) {
+ error_log(PlainLogText::of(sprintf(
+ 'Could not check the local changes of language "%s" for markup: %s',
+ $language_key,
+ $t->getMessage()
+ )));
+ continue;
+ }
+
+ $keys = array_map('strval', array_keys($policy->findInvalidValues($values)));
+ if ($keys === []) {
+ continue;
+ }
+ error_log(PlainLogText::of(sprintf(
+ 'Local changes of language "%s" contain markup that is not allowed: %s',
+ $language_key,
+ implode(', ', $keys)
+ )));
+ $this->inform(
+ $environment,
+ PlainLogText::of(
+ 'WARNING: Local changes of language ' . $language_key . ' contain HTML that is not allowed '
+ . 'in language texts (left unchanged - please correct them in the administration): '
+ . PlainLogText::keyList($keys)
+ )
+ );
+ }
+ }
+
+ /**
+ * Typically during "setup install", before the client directory exists: the overlay of local changes
+ * cannot be written, the web serves the shipped values (the build) until the next "setup update".
+ *
+ * @param list $language_keys
+ */
+ protected function informAboutMissingClientDataDirectory(Setup\Environment $environment, array $language_keys): void
+ {
+ if (
+ $language_keys === []
+ || $this->il_setup_language->getClientDataDir() !== null
+ || !$this->il_setup_language->hasMigratedModules($language_keys)
+ ) {
+ return;
+ }
+
+ $this->inform(
+ $environment,
+ 'WARNING: The client data directory is not known or does not exist yet, so the local changes '
+ . '(e.g. lang/*.lang.local) of modules maintained in PO files are not written to their overlay in '
+ . 'this run - these modules show their shipped texts without local changes until then. Run "setup '
+ . 'update" (as the web server user) once the client data directory exists.'
+ );
+ }
+
+ /**
+ * Checked before writing. Does not abort: the database is written regardless, the files are
+ * repaired by the next run with sufficient permissions.
+ *
+ * @param list $language_keys
+ */
+ protected function informAboutUnwritableOverlayDirectories(Setup\Environment $environment, array $language_keys): void
+ {
+ $unwritable_directories = $this->il_setup_language->findUnwritableOverlayDirectories($language_keys);
+ if ($unwritable_directories !== []) {
+ $this->inform(
+ $environment,
+ 'WARNING: The PO/MO language files of modules maintained in PO files cannot be written to '
+ . implode(', ', $unwritable_directories) . ' (not writable, or a file occupies the '
+ . 'directory\'s place). The languages are still installed/updated in the database, but these '
+ . 'modules keep showing outdated texts. Setup must be run as the web server user (the owner '
+ . 'of the client data directory) - re-run "setup update" as that user to repair the files.'
+ );
+ }
+
+ $this->informIfNotRunAsClientDataDirectoryOwner($environment, $language_keys);
+ }
+
+ /**
+ * Writable for Setup is not enough: files created by another user (typically root) cannot be
+ * replaced by the web server later. The owner of the client data directory counts as web server user.
+ *
+ * @param list $language_keys
+ */
+ protected function informIfNotRunAsClientDataDirectoryOwner(Setup\Environment $environment, array $language_keys = []): void
+ {
+ $client_data_dir = $this->il_setup_language->getClientDataDir();
+ if ($client_data_dir === null || !function_exists('posix_geteuid')) {
+ return;
+ }
+ $owner = @fileowner($client_data_dir);
+ $current_user = posix_geteuid();
+ if ($owner === false || ($owner === $current_user && $current_user !== 0)) {
+ return;
+ }
+ if (!$this->il_setup_language->hasMigratedModules($language_keys)) {
+ return;
+ }
+
+ $overlay_root = rtrim($client_data_dir, '/') . '/lang';
+ if ($owner === 0) {
+ $this->inform(
+ $environment,
+ sprintf(
+ 'WARNING: Setup runs as user id %d, and the client data directory %s belongs to root. '
+ . 'Running Setup as root is not supported: the web server cannot replace the PO/MO '
+ . 'language files and directories of modules maintained in PO files that this run '
+ . 'creates. Fix: run "chown -R %s" after this run, and run Setup '
+ . 'as the web server user from now on.',
+ $current_user,
+ $client_data_dir,
+ $overlay_root
+ )
+ );
+ return;
+ }
- // TODO: Remove this once ilSetupLanguage (or a successor) supports proper
- // DI for all methods.
- $db_tmp = $GLOBALS["ilDB"];
- $GLOBALS["ilDB"] = $db;
+ $owner_info = function_exists('posix_getpwuid') ? @posix_getpwuid($owner) : false;
+ $owner_name = is_array($owner_info) ? (string) $owner_info['name'] : (string) $owner;
+ $unwritable_for_owner = $this->il_setup_language->findUnwritableOverlayDirectories($language_keys, $owner);
- $this->il_setup_language->setDbHandler($db);
- $this->il_setup_language->installLanguages(
- $this->getInstallLanguages(),
- $this->getInstallLocalLanguages()
+ $this->inform(
+ $environment,
+ sprintf(
+ 'WARNING: Setup runs as user id %d, but the client data directory %s belongs to user id %d. '
+ . 'Running Setup as another user than the web server user (the owner of the client data '
+ . 'directory) is not supported%s: the PO/MO language files and directories of modules '
+ . 'maintained in PO files that this run creates belong to user id %d and cannot be '
+ . 'replaced by the web server later on.%s Fix: run "chown -R %s %s" after this run, and '
+ . 'run Setup as user %s from now on.',
+ $current_user,
+ $client_data_dir,
+ $owner,
+ $current_user === 0 ? ' (this includes root)' : '',
+ $current_user,
+ $unwritable_for_owner === []
+ ? ''
+ : ' Already not writable for user id ' . $owner . ': ' . implode(', ', $unwritable_for_owner) . '.',
+ $owner_name,
+ $overlay_root,
+ $owner_name
+ )
);
+ }
- $GLOBALS["ilDB"] = $db_tmp;
+ /**
+ * @param list $language_keys languages whose PO/MO overlay could not be written
+ */
+ protected function informAboutUnwrittenOverlays(Setup\Environment $environment, array $language_keys): void
+ {
+ if ($language_keys === []) {
+ return;
+ }
- return $environment;
+ $this->inform(
+ $environment,
+ 'WARNING: The PO/MO language files of modules maintained in PO files could not be written for '
+ . 'the languages ' . implode(', ', $language_keys) . ' (see the PHP error log for details). '
+ . 'Setup must be run as the web server user - re-run "setup update" as that user.'
+ );
}
/**
@@ -109,6 +421,91 @@ public function achieve(Setup\Environment $environment): Setup\Environment
*/
public function isApplicable(Setup\Environment $environment): bool
{
+ // getLabel() is shown between isApplicable() and achieve() and lists the languages.
+ if ($environment->getResource(Setup\Environment::RESOURCE_DATABASE) instanceof \ilDBInterface) {
+ $this->useSetupDatabase($environment);
+ $this->getInstallLanguages();
+ }
+
return true;
}
+
+ /**
+ * Must run before anything is read from ilSetupLanguage, so that the language list comes from the Setup database.
+ */
+ protected function useSetupDatabase(Setup\Environment $environment): void
+ {
+ $this->il_setup_language->setDbHandler(
+ $environment->getResource(Setup\Environment::RESOURCE_DATABASE)
+ );
+ }
+
+ /**
+ * Taken from the Setup environment, which knows it before ilias.ini.php is written;
+ * otherwise ilSetupLanguage falls back to ilias.ini.php.
+ */
+ protected function useSetupClientDataDir(Setup\Environment $environment): void
+ {
+ $ini = $environment->getResource(Setup\Environment::RESOURCE_ILIAS_INI);
+ $client_id = $environment->getResource(Setup\Environment::RESOURCE_CLIENT_ID);
+ if (!$ini instanceof ilIniFile || $client_id === null) {
+ return;
+ }
+
+ $client_data_dir = \ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths::fromDataDirAndClientId(
+ (string) $ini->readVariable('clients', 'datadir'),
+ (string) $client_id
+ );
+ if ($client_data_dir !== null) {
+ $this->il_setup_language->setClientDataDir($client_data_dir);
+ }
+ }
+
+ protected function inform(Setup\Environment $environment, string $message): void
+ {
+ $io = $environment->getResource(Setup\Environment::RESOURCE_ADMIN_INTERACTION);
+ if ($io instanceof Setup\AdminInteraction) {
+ $io->inform($message);
+ return;
+ }
+ error_log($message);
+ }
+
+ /**
+ * Installs missing languages and refreshes installed ones on every run, so that changed language files take effect.
+ *
+ * Both Activities get the full list and ignore keys that do not apply to them; UpdateLanguage also
+ * runs on just-installed keys, which is harmless.
+ *
+ * An instance with only the default directories never refreshes: that would delete the lng_data
+ * rows of component-contributed modules.
+ *
+ * @param list $language_keys
+ * @return list the languages whose PO/MO overlay could not be written (see
+ * InstallLanguage/UpdateLanguage, "overlay_write_failed_language_keys")
+ */
+ protected function installLanguages(array $language_keys): array
+ {
+ if ($language_keys === []) {
+ return [];
+ }
+
+ $install_result = $this->install_language->perform([
+ 'language_keys' => $language_keys,
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+ $overlay_write_failed = $install_result['overlay_write_failed_language_keys'] ?? [];
+
+ if (!$this->il_setup_language->usesDefaultLanguageFileDirectories()) {
+ $update_result = $this->update_language->perform([
+ 'language_keys' => $language_keys,
+ ]);
+ $overlay_write_failed = array_merge(
+ $overlay_write_failed,
+ $update_result['overlay_write_failed_language_keys'] ?? []
+ );
+ }
+
+ return array_values(array_unique($overlay_write_failed));
+ }
}
diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php
deleted file mode 100755
index 629698eaa50b..000000000000
--- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php
+++ /dev/null
@@ -1,103 +0,0 @@
-il_setup_language = $il_setup_language;
- }
-
- /**
- * @inheritDoc
- */
- public function getHash(): string
- {
- return hash("sha256", self::class);
- }
-
- /**
- * Return installed languages as string
- */
- protected function getInstalledLanguagesAsString(): string
- {
- return implode(", ", $this->il_setup_language->getInstalledLanguages());
- }
-
- /**
- * @inheritDoc
- */
- public function getLabel(): string
- {
- return "Update languages " . $this->getInstalledLanguagesAsString();
- }
-
- /**
- * @inheritDoc
- */
- public function isNotable(): bool
- {
- return true;
- }
-
- /**
- * @inheritDoc
- */
- public function getPreconditions(Setup\Environment $environment): array
- {
- return [];
- }
-
- /**
- * @inheritDoc
- */
- public function achieve(Setup\Environment $environment): Setup\Environment
- {
- $db = $environment->getResource(Setup\Environment::RESOURCE_DATABASE);
-
- // TODO: Remove this once ilSetupLanguage (or a successor) supports proper
- //// DI for all methods.
- $db_tmp = $GLOBALS["ilDB"];
- $GLOBALS["ilDB"] = $db;
-
- $this->il_setup_language->setDbHandler($db);
- $this->il_setup_language->installLanguages(
- $this->il_setup_language->getInstalledLanguages(),
- $this->il_setup_language->getLocalLanguages()
- );
-
- $GLOBALS["ilDB"] = $db_tmp;
-
- return $environment;
- }
-
- /**
- * @inheritDoc
- */
- public function isApplicable(Setup\Environment $environment): bool
- {
- return true;
- }
-}
diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php
index eb7fc44c3056..0b0231506b97 100755
--- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php
+++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php
@@ -18,6 +18,20 @@
declare(strict_types=1);
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths;
+use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync;
+use ILIAS\Language\LanguageIdentifier;
+use ILIAS\Language\Setup\InstalledLanguageRepository;
+use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository;
+use ILIAS\Language\Setup\LanguageInstallationManager;
+use ILIAS\Language\Setup\TranslationCache;
+use ILIAS\Language\Setup\NullTranslationCache;
+use ILIAS\Language\Setup\FixedInstallationRoot;
+use ILIAS\Logging\Logger\LoggerInterface;
+
/**
* language handling for setup
*
@@ -32,29 +46,126 @@
* @author Peter Gabriel
* @version $Id$
*
- *
- * @todo The DATE field is not set correctly on changes of a language (update, install, your stable).
- * The format functions do not belong in class.Language. Those are also applicable elsewhere.
- * Therefore, they would be better placed in class.Format
- * @todo This somehow needs to be reconciled with the base class and most probably be factored
- * into two classes, one for management, one for retrieval.
+ * Language implementation for Setup (txt() before the runtime DIC exists). Installation is delegated to
+ * \ILIAS\Language\Setup\InstalledLanguageRepository (read) and LanguageInstallationManager (write);
+ * kept as entry point for callers not wired through Language.php.
*/
class ilSetupLanguage extends ilLanguage
{
+ private string $absolute_path;
public array $text;
public string $lang_default = "en";
- public string $lang_path;
public string $lang_key;
public string $separator = "#:#";
public string $comment_separator = "###";
- protected ilDBInterface $db;
+ protected ?ilDBInterface $db = null;
+ private ?string $client_data_dir = null;
+ private readonly bool $uses_default_language_file_directories;
+ private readonly LanguageFileDirectoryManager $language_file_directory_manager;
+ private ?LanguageInstallationManager $manager = null;
+ private ?ilDBInterface $manager_db = null;
- public function __construct(string $a_lang_key)
- {
+ /**
+ * @param LanguageFileDirectoryManager|null $language_file_directory_manager directories contributed through the
+ * component graph; without it only main lang/ and customizing are known (usesDefaultLanguageFileDirectories())
+ * @param ilDBInterface|null $connection none for callers outside the component graph
+ * @param LoggerInterface|null $logger for the installation manager's warnings; none in the Setup (PHP error log then)
+ * @param (\Closure():?string)|null $client_data_dir_resolver without it, MigratedLanguageFilePaths::resolveClientDataDir() is used
+ */
+ public function __construct(
+ string $a_lang_key,
+ ?LanguageFileDirectoryManager $language_file_directory_manager = null,
+ private readonly ?ilDBInterface $connection = null,
+ private readonly TranslationCache $translation_cache = new NullTranslationCache(),
+ private readonly ?LoggerInterface $logger = null,
+ private readonly ?\Closure $client_data_dir_resolver = null,
+ ) {
$this->lang_key = $a_lang_key ?: $this->lang_default;
- $il_absolute_path = realpath(__DIR__ . "/../../../../../");
- $this->lang_path = $il_absolute_path . "/lang";
- $this->cust_lang_path = $il_absolute_path . "/lang/customizing";
+ $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../");
+ $this->uses_default_language_file_directories = $language_file_directory_manager === null;
+ $this->language_file_directory_manager = $language_file_directory_manager ?? new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ new MainLanguageFileDirectory()
+ );
+ $this->cust_lang_path = $this->absolute_path . "/lang/customizing";
+ $this->lang_path = $this->absolute_path . "/lang";
+ }
+
+ /**
+ * Resolved per call, so a later setDbHandler() takes effect. Order:
+ * setDbHandler(), constructor connection, $GLOBALS['ilDB'].
+ */
+ private function db(): ?ilDBInterface
+ {
+ return $this->db ?? $this->connection ?? $GLOBALS["ilDB"] ?? null;
+ }
+
+ /**
+ * Without a database only the file-based methods work.
+ */
+ private function repository(): InstalledLanguageRepository
+ {
+ return new InstalledLanguageDatabaseRepository(
+ $this->db(),
+ $this->language_file_directory_manager,
+ new FixedInstallationRoot($this->absolute_path)
+ );
+ }
+
+ /**
+ * Kept across calls because the manager collects getSkippedInvalidMarkupEntries() over a run;
+ * rebuilt only when db() changes.
+ */
+ private function manager(): LanguageInstallationManager
+ {
+ $db = $this->db() ?? throw new LogicException("No database available for " . self::class . ".");
+ if ($this->manager === null || $this->manager_db !== $db) {
+ $this->manager_db = $db;
+ $this->manager = new LanguageInstallationManager(
+ $db,
+ $this->language_file_directory_manager,
+ $this->absolute_path,
+ $this->repository(),
+ null,
+ $this->translation_cache,
+ fn(): ?string => $this->getClientDataDir(),
+ $this->logger
+ );
+ }
+ return $this->manager;
+ }
+
+ /**
+ * `null` resets to the resolver of the constructor.
+ */
+ public function setClientDataDir(?string $client_data_dir): void
+ {
+ $this->client_data_dir = $client_data_dir;
+ }
+
+ public function getClientDataDir(): ?string
+ {
+ if ($this->client_data_dir !== null) {
+ return $this->client_data_dir;
+ }
+ if ($this->client_data_dir_resolver !== null) {
+ return ($this->client_data_dir_resolver)();
+ }
+ return MigratedLanguageFilePaths::resolveClientDataDir($this->absolute_path);
+ }
+
+ /**
+ * Such an instance (e.g. `new ilSetupLanguage('en')` in plugin Setup Objectives) must not refresh an
+ * installed language: that would drop the lng_data rows of every component-contributed module.
+ */
+ public function usesDefaultLanguageFileDirectories(): bool
+ {
+ return $this->uses_default_language_file_directories;
+ }
+
+ public function getLanguageFileDirectoryManager(): LanguageFileDirectoryManager
+ {
+ return $this->language_file_directory_manager;
}
/**
@@ -62,12 +173,18 @@ public function __construct(string $a_lang_key)
*
* if the topic is not in the list, the topic itself with "-" will be returned
*
- * $a_topic topic
+ * $a_topic topic; the module of a LanguageIdentifier and $n have no effect for it in Setup
*/
- public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): string
+ public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string
{
+ /** @var ilLogger $log */
global $log;
+ if ($a_topic instanceof LanguageIdentifier) {
+ $a_topic = (string) $a_topic->value;
+ } elseif ($n !== null) {
+ return parent::txt($a_topic, $n);
+ }
if (empty($a_topic)) {
return "";
}
@@ -97,254 +214,188 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''):
*
* @return array|bool
*/
- public function installLanguages(array $a_lang_keys, array $a_local_keys)
+ public function installLanguages(array $a_lang_keys, array $a_local_keys = [])
{
- global $ilDB;
-
if (empty($a_lang_keys)) {
- $a_lang_keys = array();
+ $a_lang_keys = [];
}
- if (empty($a_local_keys)) {
- $a_local_keys = array();
- }
-
- $err_lang = array();
-
- $db_langs = $this->getAvailableLanguages();
-
- foreach ($a_lang_keys as $lang_key) {
- if ($this->checkLanguage($lang_key)) {
- $this->flushLanguage($lang_key, "keep_local");
- $this->insertLanguage($lang_key);
-
- if (in_array($lang_key, $a_local_keys, true) && is_dir($this->cust_lang_path)) {
- if ($this->checkLanguage($lang_key, "local")) {
- $this->insertLanguage($lang_key, "local");
- } else {
- $err_lang[] = $lang_key;
- }
- }
-
- // register language first time install
- if (!array_key_exists($lang_key, $db_langs)) {
- if (in_array($lang_key, $a_local_keys, true)) {
- $itype = "installed_local";
- } else {
- $itype = "installed";
- }
- $lid = $ilDB->nextId("object_data");
- $query = "INSERT INTO object_data " .
- "(obj_id,type,title,description,owner,create_date,last_update) " .
- "VALUES " .
- "(" .
- $ilDB->quote($lid, "integer") . "," .
- $ilDB->quote("lng", "text") . "," .
- $ilDB->quote($lang_key, "text") . "," .
- $ilDB->quote($itype, "text") . "," .
- $ilDB->quote("-1", "integer") . "," .
- $ilDB->now() . "," .
- $ilDB->now() .
- ")";
- $ilDB->manipulate($query);
- }
- } else {
- $err_lang[] = $lang_key;
- }
- }
+ return $this->manager()->installLanguages($a_lang_keys, $a_local_keys);
+ }
- foreach ($db_langs as $key => $val) {
- if (!in_array($key, $err_lang, true)) {
- if (in_array($key, $a_lang_keys, true)) {
- if (in_array($key, $a_local_keys, true)) {
- $ld = "installed_local";
- } else {
- $ld = "installed";
- }
- $query = "UPDATE object_data SET " .
- "description = " . $ilDB->quote($ld, "text") . ", " .
- "last_update = " . $ilDB->quote(gmdate("Y-m-d H:i:s"), "timestamp") . " " .
- "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " .
- "AND type = " . $ilDB->quote("lng", "text");
- $ilDB->manipulate($query);
- } else {
- $this->flushLanguage($key, "all");
-
- if (strpos($val["status"], "installed") === 0) {
- $query = "UPDATE object_data SET " .
- "description = " . $ilDB->quote("not_installed", "text") . ", " .
- "last_update = " . $ilDB->quote(gmdate("Y-m-d H:i:s"), "timestamp") . " " .
- "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " .
- "AND type = " . $ilDB->quote("lng", "text");
- $ilDB->manipulate($query);
- }
- }
- }
- }
+ /**
+ * Single place for the object_data row ("installed" vs. "installed_local"), also used by InstallLanguage::perform().
+ *
+ * @param array $known_languages result of getAvailableLanguages()/getAvailableLanguagesForInstallation()
+ * @param list $local_language_keys result of getLocalLanguages()
+ */
+ public function registerInstalledLanguage(
+ string $lang_key,
+ array $known_languages,
+ array $local_language_keys
+ ): void {
+ $this->manager()->registerInstalledLanguage($lang_key, $known_languages, $local_language_keys);
+ }
- return ($err_lang) ?: true;
+ public function getAvailableLanguagesForInstallation(): array
+ {
+ return $this->repository()->getAvailableLanguages();
}
/**
- * get already installed languages (in db)
+ * @return list the keys with a main language file
*/
- public function getInstalledLanguages(): array
+ public function getInstallableLanguagesForInstallation(): array
{
- global $ilDB;
-
- $arr = [];
- if ($ilDB instanceof ilDBInterface) {
- $query = "SELECT * FROM object_data " .
- "WHERE type = " . $ilDB->quote("lng", "text") . " " .
- "AND " . $ilDB->like("description", "text", "installed%");
- $r = $ilDB->query($query);
+ return array_values($this->repository()->getInstallableLanguages());
+ }
- while ($row = $ilDB->fetchObject($r)) {
- $arr[] = $row->title;
- }
- }
- return $arr;
+ public function checkLanguageForInstallation(string $lang_key): bool
+ {
+ return $this->repository()->checkLanguage($lang_key);
}
/**
- * get already installed local languages (in db)
+ * Unlike checkLanguageForInstallation(), a missing local file is not acceptable here.
*/
- public function getInstalledLocalLanguages(): array
+ public function checkLocalLanguageFileForInstallation(string $lang_key): bool
{
- global $ilDB;
+ return $this->repository()->checkLocalLanguageFile($lang_key);
+ }
- $arr = [];
- if ($ilDB instanceof ilDBInterface) {
- $query = "SELECT * FROM object_data " .
- "WHERE type = " . $ilDB->quote("lng", "text") . " " .
- "AND description = " . $ilDB->quote("installed_local", "text");
- $r = $ilDB->query($query);
+ public function flushLanguageForInstallation(string $lang_key): void
+ {
+ $this->manager()->flushLanguageForInstallation($lang_key);
+ }
- while ($row = $ilDB->fetchObject($r)) {
- $arr[] = $row->title;
- }
- }
- return $arr;
+ public function flushLanguageForUninstallation(string $lang_key): void
+ {
+ $this->manager()->flushLanguageForUninstallation($lang_key);
}
/**
- * get already registered languages (in db)
+ * @return array>>
*/
- protected function getAvailableLanguages(): array
+ public function getSkippedInvalidMarkupEntries(): array
{
- global $ilDB;
-
- $arr = array();
-
- $query = "SELECT * FROM object_data " .
- "WHERE type = " . $ilDB->quote("lng", "text");
- $r = $ilDB->query($query);
-
- while ($row = $ilDB->fetchObject($r)) {
- $arr[$row->title]["obj_id"] = $row->obj_id;
- $arr[$row->title]["status"] = $row->description;
- }
-
- return $arr;
+ return $this->manager?->getSkippedInvalidMarkupEntries() ?? [];
}
/**
- * validate the logical structure of a lang-file
- *
- * This function checks if a lang-file of a given lang_key exists,
- * the file has a header, and each lang-entry consists of exactly
- * three elements (module, identifier, value).
- *
- * $a_lang_key international language key (2 digits)
- * $scope empty (global) or "local"
- * $info_text message about results of check OR "1" if all checks successfully passed
+ * @return array>> see LanguageInstallationManager::getSkippedDuplicateEntries()
*/
- protected function checkLanguage(string $a_lang_key, string $scope = ""): bool
- {
- $scopeExtension = "";
- if (!empty($scope)) {
- if ($scope === "global") {
- $scope = "";
- } else {
- $scopeExtension = "." . $scope;
- }
- }
-
- $path = $this->lang_path;
- if ($scope === "local") {
- $path = $this->cust_lang_path;
- }
-
- $tmpPath = getcwd();
- chdir($path);
-
- // compute lang-file name format
- $lang_file = "ilias_" . $a_lang_key . ".lang" . $scopeExtension;
+ public function getSkippedDuplicateEntries(): array
+ {
+ return $this->manager?->getSkippedDuplicateEntries() ?? [];
+ }
- // file check
- if (!is_file($lang_file)) {
- chdir($tmpPath);
- return false;
- }
+ /**
+ * @return array> see LanguageInstallationManager::getSkippedUnreadableOverlays()
+ */
+ public function getSkippedUnreadableOverlays(): array
+ {
+ return $this->manager?->getSkippedUnreadableOverlays() ?? [];
+ }
- // header check
- if (!$content = $this->cut_header(file($lang_file))) {
- chdir($tmpPath);
- return false;
+ /**
+ * @return array>
+ */
+ public function getOverlaysOfUnshippedModules(): array
+ {
+ // without the contributed directories every overlay looks unshipped
+ if ($this->uses_default_language_file_directories) {
+ return [];
}
- // check (counting) elements of each lang-entry
- foreach ($content as $key => $val) {
- $separated = explode($this->separator, trim($val));
- $num = count($separated);
-
- if ($num !== 3) {
- chdir($tmpPath);
- return false;
- }
- }
+ return $this->manager?->getOverlaysOfUnshippedModules() ?? [];
+ }
- chdir($tmpPath);
+ /**
+ * @return array>
+ */
+ public function getUnrecordedTakenOverModules(): array
+ {
+ return $this->manager?->getUnrecordedTakenOverModules() ?? [];
+ }
- // no error occured
- return true;
+ /**
+ * @return list the migrated modules whose overlay could not be written
+ */
+ public function insertLanguageForInstallation(string $lang_key): array
+ {
+ return $this->manager()->insertLanguageForInstallation($lang_key);
}
/**
- * Remove *.lang header information from '$content'.
+ * See LanguageInstallationManager::insertLanguageForApplyingLocalChanges().
*
- * This function seeks for a special keyword where the language information starts.
- * If found it returns the plain language information; otherwise returns false.
+ * @return list see insertLanguageForInstallation()
+ */
+ public function insertLanguageForApplyingLocalChanges(string $lang_key): array
+ {
+ return $this->manager()->insertLanguageForApplyingLocalChanges($lang_key);
+ }
+
+ /**
+ * File-based, needs no database (unlike manager()).
*
- * $content expect an ILIAS lang-file
+ * @param list $lang_keys
+ * @param int|null $for_user_id evaluate the writability for this user instead of the current process
+ * @return list
+ */
+ public function findUnwritableOverlayDirectories(array $lang_keys, ?int $for_user_id = null): array
+ {
+ return MigratedLanguageFileSync::findUnwritableOverlayDirectories(
+ $this->language_file_directory_manager,
+ $this->absolute_path,
+ $lang_keys,
+ $this->getClientDataDir(),
+ $for_user_id
+ );
+ }
+
+ /**
+ * File-based, needs no database.
*
- * @return bool|string[]
+ * @param list $lang_keys
*/
- protected function cut_header(array $content)
+ public function hasMigratedModules(array $lang_keys): bool
{
- foreach ($content as $key => $val) {
- if (trim($val) === "") {
- return array_slice($content, $key + 1);
- }
- }
- return false;
+ return MigratedLanguageFileSync::hasShippedModules(
+ $this->language_file_directory_manager,
+ $this->absolute_path,
+ $lang_keys
+ );
}
/**
- * remove language data from database
- * $a_lang_key language key
- * $a_mode "all" or "keep_local"
+ * get already installed languages (in db)
*/
- protected function flushLanguage(string $a_lang_key, string $a_mode = "all"): void
+ public function getInstalledLanguages(): array
{
- global $ilDB;
+ return $this->repository()->getInstalledLanguages();
+ }
- self::_deleteLangData($a_lang_key, ($a_mode === "keep_local"));
+ /**
+ * get already installed local languages (in db)
+ */
+ public function getInstalledLocalLanguages(): array
+ {
+ return $this->repository()->getInstalledLocalLanguages();
+ }
- if ($a_mode === "all") {
- $ilDB->manipulate("DELETE FROM lng_modules WHERE lang_key = " .
- $ilDB->quote($a_lang_key, "text"));
- }
+ /**
+ * validate the logical structure of a lang-file
+ *
+ * This function checks if a lang-file of a given lang_key exists,
+ * the file has a header, and each lang-entry consists of exactly
+ * three elements (module, identifier, value).
+ *
+ * $a_lang_key international language key (2 digits)
+ */
+ protected function checkLanguage(string $a_lang_key): bool
+ {
+ return $this->repository()->checkLanguage($a_lang_key);
}
/**
@@ -375,204 +426,27 @@ public static function _deleteLangData(string $a_lang_key, bool $a_keep_local_ch
*/
public function getLocalChanges(string $a_lang_key, string $a_min_date = "", string $a_max_date = ""): array
{
- global $ilDB;
-
- if ($a_min_date === "") {
- $a_min_date = "1980-01-01 00:00:00";
- }
- if ($a_max_date === "") {
- $a_max_date = "2200-01-01 00:00:00";
- }
-
- $q = sprintf(
- "SELECT * FROM lng_data WHERE lang_key = %s " .
- "AND local_change >= %s AND local_change <= %s",
- $ilDB->quote($a_lang_key, "text"),
- $ilDB->quote($a_min_date, "timestamp"),
- $ilDB->quote($a_max_date, "timestamp")
- );
- $result = $ilDB->query($q);
-
- $changes = array();
- while ($row = $result->fetchRow(ilDBConstants::FETCHMODE_ASSOC)) {
- $changes[$row["module"]][$row["identifier"]] = $row["value"];
- }
- return $changes;
+ return $this->repository()->getLocalChanges($a_lang_key, $a_min_date, $a_max_date);
}
-
- //TODO: remove redundant checks here!
/**
- * insert language data from file in database
- *
- * $lang_key international language key (2 digits)
- * $scope empty (global) or "local"
+ * Searches for the existence of *.lang.local files.
+ * Returns array with language keys
*/
- protected function insertLanguage(string $lang_key, string $scope = ""): void
+ public function getLocalLanguages(): array
{
- global $ilDB;
-
- $lang_array = array();
-
- $scopeExtension = "";
- if (!empty($scope)) {
- if ($scope === "global") {
- $scope = "";
- } else {
- $scopeExtension = "." . $scope;
- }
- }
-
- $path = $this->lang_path;
- if ($scope === "local") {
- $path = $this->cust_lang_path;
- }
-
- $tmpPath = getcwd();
- chdir($path);
-
- $lang_file = "ilias_" . $lang_key . ".lang" . $scopeExtension;
- $change_date = null;
-
- if (is_file($lang_file)) {
- // initialize the array for updating lng_modules below
- $lang_array = [];
- $lang_array["common"] = [];
-
- // remove header first
- if ($content = $this->cut_header(file($lang_file))) {
- // get the local changes from the database
- if (empty($scope)) {
- $local_changes = $this->getLocalChanges($lang_key);
- } elseif ($scope === "local") {
- // set the change date to import time for a local file
- // get the modification date of the local file
- // get the newer local changes for a local file
- $change_date = gmdate("Y-m-d H:i:s", time());
- $min_date = gmdate("Y-m-d H:i:s", filemtime($lang_file));
- $local_changes = $this->getLocalChanges($lang_key, $min_date);
- }
-
- $query_check = false;
- $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES ";
- foreach ($content as $key => $val) {
- // split the line of the language file
- // [0]: module
- // [1]: identifier
- // [2]: value
- // [3]: comment (optional)
- $separated = explode($this->separator, trim($val));
-
- //get position of the comment_separator
- $pos = strpos($separated[2], $this->comment_separator);
-
- if ($pos !== false) {
- //cut comment of
- $separated[2] = substr($separated[2], 0, $pos);
- }
-
- // check if the value has a local change
- if (isset($local_changes[$separated[0]])) {
- $local_value = $local_changes[$separated[0]][$separated[1]] ?? "";
- } else {
- $local_value = "";
- }
-
- if (empty($scope)) {
- if ($local_value !== "" && $local_value !== $separated[2]) {
- // keep the locally changed value
- $lang_array[$separated[0]][$separated[1]] = $local_value;
- continue;
- }
- } elseif ($scope === "local") {
- if ($local_value !== "") {
- // keep a locally changed value that is newer than the local file
- $lang_array[$separated[0]][$separated[1]] = $local_value;
- continue;
- }
- }
-
- $query .= sprintf(
- "(%s,%s,%s,%s,%s,%s),",
- $ilDB->quote($separated[0], "text"),
- $ilDB->quote($separated[1], "text"),
- $ilDB->quote($lang_key, "text"),
- $ilDB->quote($separated[2], "text"),
- $ilDB->quote($change_date, "timestamp"),
- $ilDB->quote($separated[3] ?? null, "text")
- );
- $query_check = true;
- $lang_array[$separated[0]][$separated[1]] = $separated[2];
- }
- $query = rtrim($query, ",") . " ON DUPLICATE KEY UPDATE value=VALUES(value),remarks=VALUES(remarks);";
- if ($query_check) {
- $ilDB->manipulate($query);
- }
- }
-
- $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES ";
- $modules_to_delete = [];
- foreach ($lang_array as $module => $lang_arr) {
- if ($scope === "local") {
- $q = "SELECT * FROM lng_modules WHERE " .
- " lang_key = " . $ilDB->quote($lang_key, "text") .
- " AND module = " . $ilDB->quote($module, "text");
- $set = $ilDB->query($q);
- $row = $ilDB->fetchAssoc($set);
- if ($row === null) {
- continue;
- }
- $arr2 = unserialize($row["lang_array"], ["allowed_classes" => false]);
- if (is_array($arr2)) {
- $lang_arr = array_merge($arr2, $lang_arr);
- }
- }
- $query .= sprintf(
- "(%s,%s,%s),",
- $ilDB->quote($module, "text"),
- $ilDB->quote($lang_key, "text"),
- $ilDB->quote(serialize($lang_arr), "clob"),
- );
- $modules_to_delete[] = $module;
- }
-
- $inModulesToDelete = $ilDB->in('module', $modules_to_delete, false, 'text');
- $ilDB->manipulate(sprintf(
- "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete",
- $ilDB->quote($lang_key, "text")
- ));
-
- $query = rtrim($query, ",") . ";";
- $ilDB->manipulate($query);
- }
-
- chdir($tmpPath);
+ return $this->repository()->getLocalLanguages();
}
/**
- * Searches for the existence of *.lang.local files.
- * Returns array with language keys
+ * Local language files that contain a requested key but do not match the naming scheme.
+ *
+ * @param list $language_keys
+ * @return list
*/
- public function getLocalLanguages(): array
+ public function getInvalidLocalLanguageFiles(array $language_keys): array
{
- $local_langs = array();
- if (is_dir($this->cust_lang_path)) {
- $d = dir($this->cust_lang_path);
- $tmpPath = getcwd();
- chdir($this->cust_lang_path);
-
- // get available .lang.local files
- while ($entry = $d->read()) {
- if (is_file($entry) && (preg_match("~(^ilias_.{2}\.lang.local$)~", $entry))) {
- $lang_key = substr($entry, 6, 2);
- $local_langs[] = $lang_key;
- }
- }
-
- chdir($tmpPath);
- }
-
- return $local_langs;
+ return $this->repository()->getInvalidLocalLanguageFiles($language_keys);
}
/**
@@ -580,32 +454,17 @@ public function getLocalLanguages(): array
*/
public function getInstallableLanguages(): array
{
- $d = dir($this->lang_path);
- $tmpPath = getcwd();
- chdir($this->lang_path);
-
- $installableLanguages = [];
- // get available lang-files
- while ($entry = $d->read()) {
- if (is_file($entry) && (preg_match("~(^ilias_.{2}\.lang$)~", $entry))) {
- $lang_key = substr($entry, 6, 2);
- $installableLanguages[] = $lang_key;
- }
- }
-
- chdir($tmpPath);
-
- return $installableLanguages;
+ return $this->repository()->getInstallableLanguages();
}
/**
- * set db handler object
- * @string object db handler
+ * Takes precedence over $GLOBALS['ilDB'].
+ *
* Return true on success
*/
public function setDbHandler(ilDBInterface $a_db_handler): bool
{
- $this->db = &$a_db_handler;
+ $this->db = $a_db_handler;
return true;
}
diff --git a/components/ILIAS/Language/classes/class.ilCachedLanguage.php b/components/ILIAS/Language/classes/class.ilCachedLanguage.php
index d0e5ecfc00be..f20514c7178e 100755
--- a/components/ILIAS/Language/classes/class.ilCachedLanguage.php
+++ b/components/ILIAS/Language/classes/class.ilCachedLanguage.php
@@ -64,6 +64,9 @@ public function isForced(): bool
/**
* Return whether the global cache is active
+ *
+ * Always true, so cached_modules is always filled: ilLanguage::loadLanguageModule() must check the
+ * PO/MO overlay of a migrated module before cached_modules, or the DB cache hides the overlay.
*/
public function isActive(): bool
{
diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php
index 5275ee22bc92..5ddea200dd2e 100755
--- a/components/ILIAS/Language/classes/class.ilLanguage.php
+++ b/components/ILIAS/Language/classes/class.ilLanguage.php
@@ -18,26 +18,31 @@
declare(strict_types=1);
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths;
+use ILIAS\Language\ComponentTranslation\MigratedTranslations;
+use ILIAS\Language\ComponentTranslation\TranslationGettextRepository;
+use ILIAS\Language\ComponentTranslation\TranslationRepository;
+use ILIAS\Language\LanguageIdentifier;
+
/**
* language handling
*
* this class offers the language handling for an application.
- * it works initially on one file: languages.txt
- * from this file the class can generate many single language files.
* the constructor is called with a small language abbreviation
* e.g. $lng = new Language("en");
- * the constructor reads the single-languagefile en.lang and puts this into an array.
* with
* e.g. $lng->txt("user_updated");
* you can translate a lang-topic into the actual language
*
+ * ILIAS modules come from a TranslationRepository (the build plus overlay), never from the DB; plugin modules
+ * are read from lng_data/lng_modules.
+ * Values of a migrated module do not go into $text: loadLanguageModule() records its identifiers in
+ * $migrated_key_modules and txt() looks them up on demand.
+ *
* @author Peter Gabriel
* @version $Id$
- *
- *
- * @todo Das Datefeld wird bei Aenderungen einer Sprache (update, install, deinstall) nicht richtig gesetzt!!!
- * Die Formatfunktionen gehoeren nicht in class.Language. Die sind auch woanders einsetzbar!!!
- * Daher->besser in class.Format
*/
class ilLanguage implements \ILIAS\Language\Language
{
@@ -53,6 +58,37 @@ class ilLanguage implements \ILIAS\Language\Language
public array $loaded_modules = array();
protected static array $used_topics = array();
protected static array $used_modules = array();
+
+ /**
+ * Static, since _lookupEntry() is; taken from the list the constructor reads anyway, `null` until known.
+ *
+ * @var list|null
+ */
+ private static ?array $installed_languages = null;
+
+ /**
+ * Identifier => module; the module loaded last is responsible. An identifier the responsible migrated module
+ * does not translate gives "-identifier-", never the value of an earlier module (see txt()).
+ *
+ * @var array
+ */
+ protected array $migrated_key_modules = [];
+
+ private ?TranslationRepository $translations = null;
+
+ private static ?LanguageFileDirectoryManager $indexed_manager = null;
+
+ /**
+ * @var array
+ */
+ private static array $directories_by_prefix = [];
+
+ /**
+ * Identifier => value per non-migrated module, cached by loadLanguageModule() and txt() with a LanguageIdentifier.
+ *
+ * @var array>
+ */
+ private array $module_texts = [];
protected array $cached_modules = array();
protected array $map_modules_txt = array();
protected bool $usage_log_enabled = false;
@@ -70,9 +106,10 @@ class ilLanguage implements \ILIAS\Language\Language
* $a_lang_key language code (two characters), e.g. "de", "en", "in"
* Return false if reading failed, otherwise true
*/
- public function __construct(string $a_lang_key)
+ public function __construct(string $a_lang_key, ?TranslationRepository $translations = null)
{
global $DIC;
+ $this->translations = $translations;
$client_ini = $DIC->clientIni();
$this->log = $DIC->logger()->forComponent('lang');
@@ -99,6 +136,7 @@ public function __construct(string $a_lang_key)
}
$langs = $this->getInstalledLanguages();
+ self::$installed_languages = array_values(array_map('strval', $langs));
if (!in_array($this->lang_key, $langs, true)) {
$this->lang_key = $this->lang_default;
@@ -153,6 +191,7 @@ public function getContentLanguage(): string
/**
* gets the text for a given topic in a given language
* if the topic is not in the list, the topic itself with "-" will be returned
+ * Another language than this one is always read via gettext (_lookupEntry()), never from the injected repository.
*/
public function txtlng(string $a_module, string $a_topic, string $a_language): string
{
@@ -166,9 +205,22 @@ public function txtlng(string $a_module, string $a_topic, string $a_language): s
/**
* gets the text for a given topic
* if the topic is not in the list, the topic itself with "-" will be returned
+ *
+ * A LanguageIdentifier is read from exactly its module without loadLanguageModule() and without side effect,
+ * falling back to the default language.
+ *
+ * $n selects a plural form of a migrated module; the quantity is not inserted, callers do that. Without a
+ * form for $n in the language the value without $n is served, never another language's form.
*/
- public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string
+ public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string
{
+ if ($a_topic instanceof LanguageIdentifier) {
+ return $this->translateInModule($a_topic->module(), (string) $a_topic->value, $n);
+ }
+ if ($n !== null) {
+ // $this->txt(): a subclass (ilSetupLanguage) serves the singular as before
+ return $this->pluralTxt($a_topic, $n) ?? $this->txt($a_topic);
+ }
if (empty($a_topic)) {
return "";
}
@@ -176,28 +228,22 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""):
// remember the used topics
self::$used_topics[$a_topic] = $a_topic;
- $translation = $this->text[$a_topic] ?? "";
-
- if ($translation === "" && $a_default_lang_fallback_mod !== "") {
- // #13467 - try current language first (could be missing module)
- if ($this->lang_key != $this->lang_default) {
- $translation = self::_lookupEntry(
- $this->lang_key,
- $a_default_lang_fallback_mod,
- $a_topic
- );
- }
- // try default language last
- if ($translation === "" || $translation === "-" . $a_topic . "-") {
- $translation = self::_lookupEntry(
- $this->lang_default,
- $a_default_lang_fallback_mod,
- $a_topic
- );
+ $module = $this->migrated_key_modules[$a_topic] ?? null;
+ if ($module !== null) {
+ $translation = $this->migratedText($module, $a_topic);
+ if ($translation !== null) {
+ if ($this->usage_log_enabled) {
+ self::logUsage($module, $a_topic);
+ }
+ return $translation;
}
+ // deliberately not the value of an earlier module: with the same identifier it mostly means
+ // something else, and "-key-" is noticed. Differs from the .lang path (array_merge kept the earlier value).
+ $translation = "";
+ } else {
+ $translation = $this->text[$a_topic] ?? "";
}
-
if ($translation === "") {
if (ILIAS_LOG_ENABLED && is_object($this->log)) {
$this->log->debug("Language (" . $this->lang_key . "): topic -" . $a_topic . "- not present");
@@ -212,22 +258,136 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""):
return $translation;
}
+ /**
+ * `null` if txt() without $n applies.
+ */
+ private function pluralTxt(string $a_topic, int $n): ?string
+ {
+ $module = $a_topic === '' ? null : ($this->migrated_key_modules[$a_topic] ?? null);
+ if ($module === null) {
+ return null;
+ }
+ $value = $this->translations()->getPluralText($module, $this->migratedLangKey(), $a_topic, $n, self::clientDataDir());
+ if ($value === null || $value === '') {
+ return null;
+ }
+ self::$used_topics[$a_topic] = $a_topic;
+ if ($this->usage_log_enabled) {
+ self::logUsage($module, $a_topic);
+ }
+
+ return $value;
+ }
+
/**
* Check if language entry exists
*/
public function exists(string $a_topic): bool
{
+ $module = $this->migrated_key_modules[$a_topic] ?? null;
+ if ($module !== null) {
+ return $this->migratedText($module, $a_topic) !== null;
+ }
+
return isset($this->text[$a_topic]);
}
+ private function translateInModule(string $module, string $a_topic, ?int $n): string
+ {
+ // like txt(""): never the header of a catalog
+ if (empty($a_topic)) {
+ return '';
+ }
+ $lang_key = $this->migratedLangKey();
+ self::$used_topics[$a_topic] = $a_topic;
+
+ $translation = $this->moduleText($module, $a_topic, $n, $lang_key);
+ $default_lang_key = $this->getDefaultLanguage();
+ if ($translation === null && $lang_key !== $default_lang_key) {
+ $translation = $this->moduleText($module, $a_topic, $n, $default_lang_key);
+ }
+
+ if ($translation !== null) {
+ return $translation;
+ }
+ if (ILIAS_LOG_ENABLED && is_object($this->log)) {
+ $this->log->debug("Language (" . $lang_key . "): topic -" . $a_topic . "- not present");
+ }
+
+ return "-" . $a_topic . "-";
+ }
+
+ private function moduleText(string $module, string $a_topic, ?int $n, string $lang_key): ?string
+ {
+ if (!MigratedLanguageFilePaths::isValidLanguageKey($lang_key)) {
+ return null;
+ }
+ $translations = $this->translations();
+ if (self::migratedKeysOf($translations, $module, $lang_key) !== null) {
+ $translation = $n === null
+ ? null
+ : $translations->getPluralText($module, $lang_key, $a_topic, $n, self::clientDataDir());
+ if ($translation === null || $translation === '') {
+ $translation = $translations->getText($module, $lang_key, $a_topic, self::clientDataDir());
+ }
+ } elseif ($lang_key === $this->migratedLangKey()) {
+ $translation = $this->moduleTexts($module, $lang_key)[$a_topic] ?? null;
+ } else {
+ $translation = self::lookupDatabaseEntry($lang_key, $module, $a_topic);
+ }
+ if (!is_string($translation) || $translation === '') {
+ return null;
+ }
+
+ self::$used_modules[$module] = $module;
+ if ($this->usage_log_enabled) {
+ self::logUsage($module, $a_topic);
+ }
+
+ return $translation;
+ }
+
/**
- * Load language module
+ * Without loading the module (see txt() with a LanguageIdentifier).
+ *
+ * @return array
*/
- public function loadLanguageModule(string $a_module): void
+ private function moduleTexts(string $module, string $lang_key): array
+ {
+ if (!isset($this->module_texts[$module])) {
+ $cached = $this->cached_modules[$module] ?? null;
+ $this->module_texts[$module] = is_array($cached) ? $cached : (self::readModuleTexts($module, $lang_key) ?? []);
+ }
+
+ return $this->module_texts[$module];
+ }
+
+ /**
+ * @return array|null
+ */
+ private static function readModuleTexts(string $module, string $lang_key): ?array
{
global $DIC;
$ilDB = $DIC->database();
+ $q = "SELECT lang_array FROM lng_modules " .
+ "WHERE lang_key = " . $ilDB->quote($lang_key, "text") . " AND module = " .
+ $ilDB->quote($module, "text");
+ $r = $ilDB->query($q);
+ $row = $r->fetchRow(ilDBConstants::FETCHMODE_ASSOC);
+ if ($row === false) {
+ return null;
+ }
+ $texts = unserialize($row["lang_array"], ["allowed_classes" => false]);
+
+ return is_array($texts) ? $texts : null;
+ }
+
+ /**
+ * Load language module
+ */
+ public function loadLanguageModule(string $a_module): void
+ {
if (in_array($a_module, $this->loaded_modules, true)) {
return;
}
@@ -243,8 +403,18 @@ public function loadLanguageModule(string $a_module): void
$lang_key = $this->lang_user;
}
+ // Must run before the cached_modules check: ilCachedLanguage::isActive() is always true, and migrated
+ // modules keep a DB row on purpose (dual-write, see ilObjLanguage::syncMigratedLanguageFile()).
+ $migrated_keys = self::migratedKeysOf($this->translations(), $a_module, $lang_key);
+ if ($migrated_keys !== null) {
+ // the module loaded last is responsible for its identifiers
+ $this->migrated_key_modules = $migrated_keys + $this->migrated_key_modules;
+ return;
+ }
+
if (isset($this->cached_modules[$a_module]) && is_array($this->cached_modules[$a_module])) {
$this->text = array_merge($this->text, $this->cached_modules[$a_module]);
+ $this->forgetMigratedKeys($this->cached_modules[$a_module]);
if ($this->usage_log_enabled) {
foreach (array_keys($this->cached_modules[$a_module]) as $key) {
@@ -255,19 +425,11 @@ public function loadLanguageModule(string $a_module): void
return;
}
- $q = "SELECT * FROM lng_modules " .
- "WHERE lang_key = " . $ilDB->quote($lang_key, "text") . " AND module = " .
- $ilDB->quote($a_module, "text");
- $r = $ilDB->query($q);
- $row = $r->fetchRow(ilDBConstants::FETCHMODE_ASSOC);
-
- if ($row === false) {
- return;
- }
-
- $new_text = unserialize($row["lang_array"], ["allowed_classes" => false]);
- if (is_array($new_text)) {
+ $new_text = self::readModuleTexts($a_module, $lang_key);
+ if ($new_text !== null) {
+ $this->module_texts[$a_module] = $new_text;
$this->text = array_merge($this->text, $new_text);
+ $this->forgetMigratedKeys($new_text);
if ($this->usage_log_enabled) {
foreach (array_keys($new_text) as $key) {
@@ -277,6 +439,130 @@ public function loadLanguageModule(string $a_module): void
}
}
+ /**
+ * A module that is not migrated, loaded after migrated ones, wins for its identifiers.
+ *
+ * @param array $text
+ */
+ private function forgetMigratedKeys(array $text): void
+ {
+ if ($this->migrated_key_modules !== []) {
+ $this->migrated_key_modules = array_diff_key($this->migrated_key_modules, $text);
+ }
+ }
+
+ private function migratedText(string $module, string $a_topic): ?string
+ {
+ return $this->translations()->getText($module, $this->migratedLangKey(), $a_topic, self::clientDataDir());
+ }
+
+ /** The default repository has static state, so all instances and the static methods share it. */
+ private function translations(): TranslationRepository
+ {
+ return $this->translations ??= new TranslationGettextRepository();
+ }
+
+ private function migratedLangKey(): string
+ {
+ return $this->lang_key !== '' ? $this->lang_key : $this->lang_user;
+ }
+
+ /**
+ * The migrated state is only served for installed languages; the list is the one the constructor already
+ * determined, without a query of its own. Without it (no ilLanguage constructed yet) nothing is restricted.
+ */
+ private static function isInstalledLanguage(string $lang_key): bool
+ {
+ return self::$installed_languages === null || in_array($lang_key, self::$installed_languages, true);
+ }
+
+ /**
+ * $lang_key was uninstalled in this request: its migrated state is no longer served (see isInstalledLanguage()),
+ */
+ public static function forgetInstalledLanguage(string $lang_key): void
+ {
+ if (self::$installed_languages !== null) {
+ self::$installed_languages = array_values(array_diff(self::$installed_languages, [$lang_key]));
+ }
+ (new TranslationGettextRepository())->invalidateLanguage($lang_key);
+ }
+
+ /**
+ * Makes an overlay write (see MigratedLanguageFileSync) visible to the rest of the request.
+ */
+ public static function invalidateMigratedLanguageFileCache(string $a_module, string $lang_key): void
+ {
+ (new TranslationGettextRepository())->invalidateModule($a_module, $lang_key);
+ }
+
+ /**
+ * `null` only for a module the components do not ship (a plugin's): the caller reads lng_modules.
+ * A shipped module without a build has no identifiers (reported if its `.po` exists), no DB fallback.
+ * Whether $a_module goes through $translations is decided by isShippedModule() and isInstalledLanguage(), not by it.
+ *
+ * @return array|null
+ */
+ private static function migratedKeysOf(TranslationRepository $translations, string $a_module, string $lang_key): ?array
+ {
+ if (!self::isShippedModule($a_module)) {
+ return null;
+ }
+ if (!MigratedLanguageFilePaths::isValidLanguageKey($lang_key) || !self::isInstalledLanguage($lang_key)) {
+ return [];
+ }
+ $keys = $translations->getKeys($a_module, $lang_key, self::clientDataDir());
+ if ($keys !== null || MigratedTranslations::reportMissingBuild() || !self::isShippedWithoutBuild($a_module, $lang_key)) {
+ return $keys ?? [];
+ }
+ MigratedTranslations::reportProblem(sprintf(
+ 'Module "%s" ships a language file for "%s" that is not built - if "php cli/setup.php build" warns'
+ . ' that the file cannot be compiled, fix the file; otherwise run the build.',
+ $a_module,
+ $lang_key
+ ));
+
+ return [];
+ }
+
+ private static function isShippedModule(string $a_module): bool
+ {
+ return MigratedTranslations::isShippedModule($a_module) || self::findLanguageFileDirectory($a_module) !== null;
+ }
+
+ private static function isShippedWithoutBuild(string $a_module, string $lang_key): bool
+ {
+ return MigratedTranslations::shipsPoWithoutBuild($a_module, $lang_key, self::findLanguageFileDirectory($a_module));
+ }
+
+ private static function clientDataDir(): ?string
+ {
+ return MigratedTranslations::clientDataDir();
+ }
+
+ /**
+ * Assembled by the Language.php component graph and re-exposed to legacy $DIC code by AllModernComponents.php.
+ */
+ private static function findLanguageFileDirectory(string $a_module): ?LanguageFileDirectory
+ {
+ global $DIC;
+
+ if (!$DIC instanceof \ArrayAccess || !$DIC->offsetExists(LanguageFileDirectoryManager::class)) {
+ return null;
+ }
+
+ /** @var LanguageFileDirectoryManager $manager */
+ $manager = $DIC[LanguageFileDirectoryManager::class];
+ if ($manager !== self::$indexed_manager) {
+ self::$directories_by_prefix = [];
+ foreach ($manager->getDirectories() as $directory) {
+ self::$directories_by_prefix[$directory->getPrefix()] ??= $directory;
+ }
+ self::$indexed_manager = $manager;
+ }
+
+ return self::$directories_by_prefix[$a_module] ?? null;
+ }
+
/**
* Get installed languages
*/
@@ -287,6 +573,9 @@ public function getInstalledLanguages(): array
/**
* Get installed languages
+ *
+ * Not mergeable with ilSetupLanguage::getInstalledLanguages(): ilObject::_getObjectsByType() is
+ * unavailable during Setup, which uses a raw object_data query.
*/
public static function _getInstalledLanguages(): array
{
@@ -304,19 +593,27 @@ public static function _getInstalledLanguages(): array
public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a_id): string
{
- global $DIC;
- $ilDB = $DIC->database();
+ $translations = new TranslationGettextRepository();
+ $migrated_keys = self::migratedKeysOf($translations, $a_mod, $a_lang_key);
+ if ($migrated_keys !== null) {
+ $value = isset($migrated_keys[$a_id])
+ ? $translations->getText($a_mod, $a_lang_key, $a_id, self::clientDataDir())
+ : null;
+ if ($value === null || $value === '') {
+ return "-" . $a_id . "-";
+ }
+ self::$used_topics[$a_id] = $a_id;
+ self::$used_modules[$a_mod] = $a_mod;
- $set = $ilDB->query($q = sprintf(
- "SELECT * FROM lng_data WHERE module = %s " .
- "AND lang_key = %s AND identifier = %s",
- $ilDB->quote($a_mod, "text"),
- $ilDB->quote($a_lang_key, "text"),
- $ilDB->quote($a_id, "text")
- ));
- $rec = $ilDB->fetchAssoc($set);
+ if (self::isUsageLogEnabled()) {
+ self::logUsage($a_mod, $a_id);
+ }
+
+ return $value;
+ }
- if (isset($rec["value"]) && $rec["value"] != "") {
+ $value = self::lookupDatabaseEntry($a_lang_key, $a_mod, $a_id);
+ if ($value !== null) {
// remember the used topics
self::$used_topics[$a_id] = $a_id;
self::$used_modules[$a_mod] = $a_mod;
@@ -325,12 +622,29 @@ public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a
self::logUsage($a_mod, $a_id);
}
- return $rec["value"];
+ return $value;
}
return "-" . $a_id . "-";
}
+ private static function lookupDatabaseEntry(string $a_lang_key, string $a_mod, string $a_id): ?string
+ {
+ global $DIC;
+ $ilDB = $DIC->database();
+
+ $set = $ilDB->query(sprintf(
+ "SELECT value FROM lng_data WHERE module = %s " .
+ "AND lang_key = %s AND identifier = %s",
+ $ilDB->quote($a_mod, "text"),
+ $ilDB->quote($a_lang_key, "text"),
+ $ilDB->quote($a_id, "text")
+ ));
+ $rec = $ilDB->fetchAssoc($set);
+
+ return isset($rec["value"]) && $rec["value"] != "" ? (string) $rec["value"] : null;
+ }
+
/**
* Lookup obj_id of language
*/
@@ -500,7 +814,11 @@ public function toJSMap(array $a_map, ?ilGlobalTemplateInterface $a_tpl = null):
foreach ($a_map as $k => $v) {
if ($v != "") {
- $a_tpl->addOnloadCode("il.Language.setLangVar('" . $k . "', " . json_encode($v, JSON_THROW_ON_ERROR) . ");");
+ // HEX flags: a value or key can never end the surrounding ',
+ ],
+ 'usr_id' => 6,
+ ]);
+ $this->fail('Expected an InvalidInputException to be thrown.');
+ } catch (InvalidInputException $e) {
+ $this->assertStringContainsString('fr', $e->getMessage());
+ $this->assertStringNotContainsString('alert(1)', $e->getMessage());
+ $this->assertStringNotContainsString('script', $e->getMessage());
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+ }
+
+ /**
+ * A form key missing from the shipped message (here "item [2]", reported by the provider) rejects the request
+ * before any write, naming the language but not the value.
+ */
+ public function testAFormKeyWithNoSuchFormInAShippedMessageRejectsTheRequestBeforeAnyWrite(): void
+ {
+ $calls = [];
+ $asked = [];
+ $activity = $this->createActivity(
+ ['de', 'fr'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls),
+ languages_without_plural_form: static function (string $module, string $identifier, array $lang_keys) use (&$asked): array {
+ $asked[] = [$module, $identifier, $lang_keys];
+ return ['de'];
+ }
+ );
+
+ try {
+ $activity->perform([
+ 'module' => 'poll',
+ 'identifier' => 'item [2]',
+ 'translations' => ['de' => 'Ein geheimer Wert', 'fr' => ''],
+ 'usr_id' => 6,
+ ]);
+ $this->fail('Expected an InvalidInputException to be thrown.');
+ } catch (InvalidInputException $e) {
+ $this->assertStringContainsString('de', $e->getMessage());
+ $this->assertStringNotContainsString('Ein geheimer Wert', $e->getMessage());
+ // only the languages with a value are asked
+ $this->assertSame([['poll', 'item [2]', ['de']]], $asked);
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+ }
+
+ public function testAnIdentifierExistingInAnotherModuleRejectsTheRequestBeforeAnyWrite(): void
+ {
+ $calls = [];
+ $asked = [];
+ $activity = $this->createActivity(
+ ['de', 'en'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls),
+ duplicates_of: static function (string $module, string $identifier) use (&$asked): array {
+ $asked[] = [$module, $identifier];
+ return ['mme: component_not_active'];
+ }
+ );
+
+ try {
+ $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'component_not_active',
+ 'translations' => ['de' => 'Inaktiv', 'en' => 'Inactive'],
+ ]);
+ $this->fail('Expected an InvalidInputException to be thrown.');
+ } catch (InvalidInputException $e) {
+ $this->assertStringContainsString('mme: component_not_active', $e->getMessage());
+ $this->assertSame(400, $e->getCode());
+ $this->assertSame([['common', 'component_not_active']], $asked);
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+ }
+
+ // same de/en rule as the perform() test above, via maybePerformAs()
+ public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de', 'en'],
+ rbac: $rbac,
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [
+ 'de' => 'Hallo',
+ 'en' => ' ', // whitespace-only counts as blank too
+ ],
+ ]);
+
+ $this->assertTrue($result->isError());
+ $this->assertInstanceOf(InvalidInputException::class, $result->error());
+ $this->assertSame(400, $result->error()->getCode());
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+
+ public function testPerformRejectsWholeRequestAndWritesNothingWhenDeIsBlankWhileEnIsValid(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de', 'en'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $this->expectException(InvalidInputException::class);
+ try {
+ $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['en' => 'Hello'],
+ 'usr_id' => 6,
+ ]);
+ } finally {
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+ }
+
+ public function testDeAndEnNotInstalledAtAllImposesNoMandatoryRequirement(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ ['fr'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame([], $result['added_language_keys']);
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+ $this->assertArrayNotHasKey('replace', $calls);
+ }
+
+ public static function invalidPerformParametersProvider(): array
+ {
+ return [
+ 'missing module' => [['identifier' => 'foo', 'translations' => [], 'usr_id' => 6]],
+ 'empty string module' => [['module' => '', 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]],
+ 'non-string module' => [['module' => 42, 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]],
+ 'missing identifier' => [['module' => 'common', 'translations' => [], 'usr_id' => 6]],
+ 'empty string identifier' => [['module' => 'common', 'identifier' => '', 'translations' => [], 'usr_id' => 6]],
+ 'non-string identifier' => [['module' => 'common', 'identifier' => [], 'translations' => [], 'usr_id' => 6]],
+ 'missing translations' => [['module' => 'common', 'identifier' => 'foo', 'usr_id' => 6]],
+ 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope', 'usr_id' => 6]],
+ // a missing/null usr_id is allowed; only a non-null usr_id of the wrong type is invalid
+ 'non-int usr_id' => [['module' => 'common', 'identifier' => 'foo', 'translations' => [], 'usr_id' => '6']],
+ ];
+ }
+
+ #[DataProvider('invalidPerformParametersProvider')]
+ public function testPerformRejectsInvalidOrIncompleteParameters(array $parameters): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity(['de'])->perform($parameters);
+ }
+
+ public function testPerformRejectsNonArrayParameters(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity(['de'])->perform('not-an-array');
+ }
+
+ /**
+ * The fake repository returns different installed-language lists on the 1st (getInputDescription()) and
+ * 2nd (perform()) call.
+ */
+ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $calls = 0;
+ $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array {
+ $calls++;
+ // 1st call: 'fr' not installed yet; 2nd call: installed
+ return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr'];
+ });
+
+ $activity = $this->createActivity(
+ [],
+ rbac: $rbac,
+ installed_language_repository: $repository
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello'],
+ ]);
+
+ $this->assertFalse($result->isError());
+ $value = $result->value();
+ $this->assertSame(['de', 'en'], $value['added_language_keys']);
+ $this->assertSame(['fr'], $value['skipped_empty_language_keys']);
+ // two independent snapshots, not a cached one
+ $this->assertSame(2, $calls);
+ }
+
+ // fail-closed: "en" becomes mandatory after the form was built without it; the request must be rejected
+ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $calls = 0;
+ $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array {
+ $calls++;
+ // 1st call: only "de" installed; 2nd call: "en" installed
+ return $calls === 1 ? ['de'] : ['de', 'en'];
+ });
+
+ $calls_to_write_closures = [];
+ $activity = $this->createActivity(
+ [],
+ rbac: $rbac,
+ installed_language_repository: $repository,
+ replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures),
+ update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures)
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo'],
+ ]);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame('A value is required for: en.', $error->getMessage());
+ $this->assertArrayNotHasKey('replace', $calls_to_write_closures);
+ $this->assertArrayNotHasKey('cache', $calls_to_write_closures);
+ // two independent snapshots, not a cached one
+ $this->assertSame(2, $calls);
+ }
+
+ /**
+ * An optional language uninstalled between the two calls has its submitted value silently dropped.
+ */
+ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $calls = 0;
+ $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array {
+ $calls++;
+ // 1st call: "fr" installed; 2nd call: uninstalled
+ return $calls === 1 ? ['de', 'en', 'fr'] : ['de', 'en'];
+ });
+
+ $calls_to_write_closures = [];
+ $activity = $this->createActivity(
+ [],
+ rbac: $rbac,
+ installed_language_repository: $repository,
+ replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures),
+ update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures)
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'],
+ ]);
+
+ $this->assertFalse($result->isError());
+ $value = $result->value();
+ $this->assertSame(['de', 'en'], $value['added_language_keys']);
+ $this->assertSame([], $value['skipped_empty_language_keys']);
+ $this->assertNotContains('fr', $value['added_language_keys']);
+ $this->assertNotContains('fr', $value['skipped_empty_language_keys']);
+ $this->assertArrayHasKey('replace', $calls_to_write_closures);
+ $written_lang_keys = array_column($calls_to_write_closures['replace'], 2);
+ $this->assertSame(['de', 'en'], $written_lang_keys);
+ $this->assertNotContains('fr', $written_lang_keys);
+ // two independent snapshots, not a cached one
+ $this->assertSame(2, $calls);
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php
new file mode 100644
index 000000000000..ee700fa810cb
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php
@@ -0,0 +1,452 @@
+ 'login-' . $usr_id;
+
+ $activity = $this->createActivity(
+ ['de', 'en', 'fr'],
+ replace_lang_entry: $replace_lang_entry,
+ update_module_cache: $update_module_cache,
+ user_login: $user_login
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [
+ 'de' => 'Hallo',
+ 'en' => 'Hello',
+ ],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame('common', $result['module']);
+ $this->assertSame('new_topic', $result['identifier']);
+ $this->assertSame(['de', 'en'], $result['added_language_keys']);
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+
+ $this->assertCount(2, $calls['replace']);
+ $this->assertSame(['common', 'new_topic', 'de', 'Hallo'], array_slice($calls['replace'][0], 0, 4));
+ $this->assertSame('login-6', $calls['replace'][0][5]);
+ $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $calls['replace'][0][4]);
+ $this->assertSame(['common', 'new_topic', 'en', 'Hello'], array_slice($calls['replace'][1], 0, 4));
+
+ $this->assertCount(2, $calls['cache']);
+ $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]);
+ $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]);
+
+ foreach ($calls['replace'] as $call) {
+ $this->assertNotSame('fr', $call[2]);
+ }
+ foreach ($calls['cache'] as $call) {
+ $this->assertNotSame('fr', $call[0]);
+ }
+ }
+
+ /**
+ * update_module_cache() returns `false` when the PO/MO overlay of a PO-maintained module could not be written:
+ * the language is listed in "overlay_write_failed_language_keys" but still counts as added.
+ */
+ public function testALanguageWhoseModuleCacheUpdateReportsAnUnwrittenOverlayIsListed(): void
+ {
+ $results = ['de' => false, 'en' => true, 'fr' => null];
+ $update_module_cache = static fn(
+ string $lang_key,
+ string $module,
+ string $identifier,
+ string $value
+ ): ?bool => $results[$lang_key];
+
+ $result = $this->createActivity(['de', 'en', 'fr'], update_module_cache: $update_module_cache)->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame(['de', 'en', 'fr'], $result['added_language_keys']);
+ $this->assertSame(['de'], $result['overlay_write_failed_language_keys']);
+ }
+
+ /**
+ * A language whose module content cannot be read is skipped before anything is written (not even lng_data) and
+ * reported as unwritten instead of added; the other languages are written.
+ */
+ public function testALanguageWithUnreadableModuleContentIsReportedUnwrittenAndNeverWritten(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de', 'en', 'fr'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls),
+ has_unreadable_content: static fn(string $lang_key, string $module): bool => $lang_key === 'en'
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => ''],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame(['de'], $result['added_language_keys']);
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+ $this->assertSame(['en'], $result['overlay_write_failed_language_keys']);
+ $this->assertSame(['de'], array_column($calls['replace'], 2));
+ $this->assertSame(['de'], array_column($calls['cache'], 0));
+ }
+
+ /**
+ * A skipped language never reaches update_module_cache(), so it is never listed as unwritten.
+ */
+ public function testASkippedLanguageIsNeverListedAsHavingAnUnwrittenOverlay(): void
+ {
+ $update_module_cache = static fn(
+ string $lang_key,
+ string $module,
+ string $identifier,
+ string $value
+ ): bool => false;
+
+ $result = $this->createActivity(['de', 'en', 'fr'], update_module_cache: $update_module_cache)->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello'],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+ $this->assertSame(['de', 'en'], $result['overlay_write_failed_language_keys']);
+ }
+
+ /**
+ * Deliberately not transactional: if replace_lang_entry() throws on the 3rd of 3 languages, the earlier
+ * writes stay and the exception propagates.
+ */
+ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void
+ {
+ $calls = [];
+ $replace_lang_entry = static function (
+ string $module,
+ string $identifier,
+ string $lang_key,
+ string $value,
+ string $local_change,
+ string $remarks
+ ) use (&$calls): bool {
+ $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks];
+ if ($lang_key === 'fr') {
+ throw new \RuntimeException('simulated database error while writing "fr"');
+ }
+ return true;
+ };
+
+ $activity = $this->createActivity(
+ ['de', 'en', 'fr'],
+ replace_lang_entry: $replace_lang_entry,
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ try {
+ $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [
+ 'de' => 'Hallo',
+ 'en' => 'Hello',
+ 'fr' => 'Bonjour',
+ ],
+ 'usr_id' => 6,
+ ]);
+ $this->fail('Expected the simulated \RuntimeException to propagate out of perform().');
+ } catch (\RuntimeException $e) {
+ $this->assertSame('simulated database error while writing "fr"', $e->getMessage());
+
+ // "de" and "en", written before the failing "fr", remain written
+ $this->assertSame(
+ ['common', 'new_topic', 'de', 'Hallo', $calls['replace'][0][4], 'default-login'],
+ $calls['replace'][0]
+ );
+ $this->assertSame(
+ ['common', 'new_topic', 'en', 'Hello', $calls['replace'][1][4], 'default-login'],
+ $calls['replace'][1]
+ );
+ $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]);
+ $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]);
+
+ // "fr" was attempted but never reached update_module_cache()
+ $this->assertCount(3, $calls['replace']);
+ $this->assertCount(2, $calls['cache']);
+ }
+ }
+
+ /**
+ * Markup that TranslationMarkupPolicy allows (plain tag, link with allowed scheme) never blocks the request.
+ */
+ public function testAllowedMarkupInEveryLanguageIsWritten(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de', 'en'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [
+ 'de' => 'Hallo',
+ 'en' => 'Hello',
+ ],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame(['de', 'en'], $result['added_language_keys']);
+ $this->assertSame('Hallo', $calls['replace'][0][3]);
+ $this->assertSame('Hello', $calls['replace'][1][3]);
+ }
+
+ public function testWhitespaceOnlyValueIsTreatedAsEmptyAndSkipped(): void
+ {
+ // 'fr' avoids the separate 'de'/'en' mandatory-value rule
+ $calls = [];
+ $activity = $this->createActivity(
+ ['fr'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['fr' => " \t "],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame([], $result['added_language_keys']);
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+
+ public function testValueSurroundedByWhitespaceIsTrimmedBeforeBeingWritten(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => " Hallo Welt "],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame(['de'], $result['added_language_keys']);
+ $this->assertSame('Hallo Welt', $calls['replace'][0][3]);
+ $this->assertSame('Hallo Welt', $calls['cache'][0][3]);
+ }
+
+ /**
+ * A non-string value (direct perform() caller bypassing normalizeParameters()) counts as empty.
+ */
+ public function testNonStringTranslationValueIsTreatedAsEmptyAndSkippedRatherThanCoerced(): void
+ {
+ // 'fr' avoids the separate 'de'/'en' mandatory-value rule
+ $calls = [];
+ $activity = $this->createActivity(
+ ['fr'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['fr' => 42],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame([], $result['added_language_keys']);
+ $this->assertSame(['fr'], $result['skipped_empty_language_keys']);
+ $this->assertArrayNotHasKey('replace', $calls);
+ }
+
+ public function testNoInstalledLanguagesResultsInEmptyBucketsAndNoWrites(): void
+ {
+ $calls = [];
+ $activity = $this->createActivity(
+ [],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls)
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo'],
+ 'usr_id' => 6,
+ ]);
+
+ $this->assertSame([], $result['added_language_keys']);
+ $this->assertSame([], $result['skipped_empty_language_keys']);
+ $this->assertArrayNotHasKey('replace', $calls);
+ $this->assertArrayNotHasKey('cache', $calls);
+ }
+
+ public function testUserLoginClosureIsCalledExactlyOnceWithTheGivenUsrIdRegardlessOfLanguageCount(): void
+ {
+ $login_calls = [];
+ $user_login = static function (int $usr_id) use (&$login_calls): string {
+ $login_calls[] = $usr_id;
+ return 'resolved-login';
+ };
+
+ $activity = $this->createActivity(['de', 'en'], user_login: $user_login);
+
+ $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello'],
+ 'usr_id' => 99,
+ ]);
+
+ $this->assertSame([99], $login_calls);
+ }
+
+ // a missing usr_id is allowed: a caller following the plain Activity contract never supplies one
+ public function testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure(): void
+ {
+ $login_calls = [];
+ $user_login = static function (int $usr_id) use (&$login_calls): string {
+ $login_calls[] = $usr_id;
+ return 'must-not-be-used';
+ };
+
+ $calls = [];
+ $activity = $this->createActivity(
+ ['de', 'en'],
+ replace_lang_entry: $this->spyReplaceLangEntry($calls),
+ update_module_cache: $this->spyUpdateModuleCache($calls),
+ user_login: $user_login
+ );
+
+ $result = $activity->perform([
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello'],
+ ]);
+
+ $this->assertSame(['de', 'en'], $result['added_language_keys']);
+ $this->assertSame([], $login_calls, 'user_login() must never be called when usr_id is absent.');
+ $this->assertSame('', $calls['replace'][0][5]);
+ $this->assertSame('', $calls['replace'][1][5]);
+ }
+
+ /**
+ * A missing or null usr_id must succeed; only a non-null usr_id of the wrong type is rejected.
+ */
+ public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array
+ {
+ return [
+ 'missing usr_id' => [
+ ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo', 'en' => 'Hello']],
+ ],
+ 'null usr_id' => [
+ [
+ 'module' => 'common',
+ 'identifier' => 'foo',
+ 'translations' => ['de' => 'Hallo', 'en' => 'Hello'],
+ 'usr_id' => null,
+ ],
+ ],
+ ];
+ }
+
+ #[DataProvider('validUsrIdMissingOrNullWithValidTranslationsProvider')]
+ public function testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations(array $parameters): void
+ {
+ $activity = $this->createActivity(['de', 'en']);
+
+ $result = $activity->perform($parameters);
+
+ $this->assertSame('common', $result['module']);
+ $this->assertSame('foo', $result['identifier']);
+ $this->assertSame(['de', 'en'], $result['added_language_keys']);
+ }
+
+ /**
+ * An empty translations array is legal: every installed language is reported skipped, not rejected.
+ */
+ public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLanguage(): void
+ {
+ // 'fr'/'it' avoid the separate 'de'/'en' mandatory-value rule
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $activity = $this->createActivity(['fr', 'it'], rbac: $rbac);
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [
+ 'module' => 'common',
+ 'identifier' => 'new_topic',
+ 'translations' => [],
+ ]);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame([], $result->value()['added_language_keys']);
+ $this->assertSame(['fr', 'it'], $result->value()['skipped_empty_language_keys']);
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/FailureTest.php b/components/ILIAS/Language/tests/Activities/FailureTest.php
new file mode 100644
index 000000000000..43a75f656087
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/FailureTest.php
@@ -0,0 +1,87 @@
+>|list> */
+ public static function clientErrorProvider(): array
+ {
+ return [
+ 'invalid input (400)' => [new InvalidInputException('bad')],
+ 'forbidden (403)' => [new \DomainException('nope', 403)],
+ 'ambiguous title (409)' => [new AmbiguousLanguageTitleException('twice')],
+ 'lowest 4xx' => [new \RuntimeException('x', 400)],
+ 'highest 4xx' => [new \RuntimeException('x', 499)],
+ ];
+ }
+
+ #[DataProvider('clientErrorProvider')]
+ public function testOf4xxExceptionPassesThroughUnchanged(\Exception $e): void
+ {
+ $this->assertSame($e, Failure::of($e));
+ }
+
+ /** @return array>|list> */
+ public static function defectProvider(): array
+ {
+ return [
+ 'code 0' => [new \RuntimeException('SQL: secret', 0)],
+ 'below 4xx (399)' => [new \RuntimeException('secret', 399)],
+ 'above 4xx (500)' => [new \RuntimeException('secret', 500)],
+ 'above 4xx (600)' => [new \RuntimeException('secret', 600)],
+ 'Error with 4xx code' => [new \TypeError('secret', 400)],
+ 'plain Error' => [new \Error('secret')],
+ 'PDOException, SQLSTATE 42S02 as string code' => [self::pdoExceptionWithSqlState('42S02')],
+ 'PDOException, SQLSTATE 42S22 as string code' => [self::pdoExceptionWithSqlState('42S22')],
+ ];
+ }
+
+ /** PDOException carries its SQLSTATE as string code, which compares numerically against 400..499 in PHP 8. */
+ private static function pdoExceptionWithSqlState(string $sql_state): \PDOException
+ {
+ return new class ('SQLSTATE: secret table', $sql_state) extends \PDOException {
+ public function __construct(string $message, string $sql_state)
+ {
+ parent::__construct($message);
+ $this->code = $sql_state;
+ }
+ };
+ }
+
+ #[DataProvider('defectProvider')]
+ public function testOfMasksEverythingElseAsGeneric500KeepingTheOriginalAsPrevious(\Throwable $defect): void
+ {
+ $failure = Failure::of($defect);
+
+ $this->assertNotSame($defect, $failure);
+ $this->assertInstanceOf(\RuntimeException::class, $failure);
+ $this->assertSame(500, $failure->getCode());
+ $this->assertSame('The activity could not be performed.', $failure->getMessage());
+ $this->assertSame($defect, $failure->getPrevious());
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php b/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php
new file mode 100644
index 000000000000..8f1a2890a019
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php
@@ -0,0 +1,87 @@
+installed_languages)();
+ }
+
+ public function getInstalledLocalLanguages(): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getAvailableLanguages(): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getLanguageEntries(string $lang_key): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getLocalLanguages(): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getInstallableLanguages(): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function getInvalidLocalLanguageFiles(array $language_keys): array
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function checkLanguage(string $lang_key): bool
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+
+ public function checkLocalLanguageFile(string $lang_key): bool
+ {
+ throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.');
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php b/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php
new file mode 100644
index 000000000000..893e51fbf418
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php
@@ -0,0 +1,83 @@
+ $unwritten_overlay_modules
+ */
+ public function __construct(
+ private readonly bool $is_system_language,
+ private readonly bool $is_user_language,
+ private readonly bool $is_installed,
+ private readonly string $uninstall_return_value = 'uninstalled',
+ private readonly array $unwritten_overlay_modules = [],
+ ) {
+ }
+
+ public function isSystemLanguage(): bool
+ {
+ return $this->is_system_language;
+ }
+
+ public function isUserLanguage(): bool
+ {
+ return $this->is_user_language;
+ }
+
+ public function isInstalled(): bool
+ {
+ return $this->is_installed;
+ }
+
+ public function uninstall(): string
+ {
+ $this->uninstall_calls++;
+
+ return $this->uninstall_return_value;
+ }
+
+ /**
+ * @return list modules whose overlay the last uninstall() could not remove
+ */
+ public function getModulesWithUnwrittenOverlay(): array
+ {
+ return $this->unwritten_overlay_modules;
+ }
+
+ public function uninstallCallCount(): int
+ {
+ return $this->uninstall_calls;
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php b/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php
new file mode 100644
index 000000000000..c56ff172640e
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php
@@ -0,0 +1,69 @@
+ $modules_with_unwritten_overlay migrated modules whose PO/MO overlay could not be written
+ */
+ public function __construct(
+ private readonly bool $is_installed,
+ private readonly bool $remove_local_changes_return_value = true,
+ private readonly array $modules_with_unwritten_overlay = [],
+ ) {
+ }
+
+ public function isInstalled(): bool
+ {
+ return $this->is_installed;
+ }
+
+ public function removeLocalChanges(): bool
+ {
+ $this->remove_local_changes_calls++;
+
+ return $this->remove_local_changes_return_value;
+ }
+
+ /**
+ * @return list
+ */
+ public function getModulesWithUnwrittenOverlay(): array
+ {
+ return $this->modules_with_unwritten_overlay;
+ }
+
+ public function removeLocalChangesCallCount(): int
+ {
+ return $this->remove_local_changes_calls;
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php
new file mode 100644
index 000000000000..18f39a391ce0
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php
@@ -0,0 +1,249 @@
+expectException(\LogicException::class);
+
+ $this->host()->callGrind($this->createStub(FormInput::class), []);
+ }
+
+ public static function checkboxWhitelistAcceptsRawValueProvider(): array
+ {
+ return [
+ 'bool true' => [true, true],
+ 'int 1' => [1, true],
+ 'string "1"' => ['1', true],
+ 'string "true"' => ['true', true],
+ 'string "checked"' => ['checked', true],
+ 'string "on"' => ['on', true],
+ 'bool false' => [false, false],
+ 'int 0' => [0, false],
+ 'string "0"' => ['0', false],
+ 'string "false"' => ['false', false],
+ 'empty string' => ['', false],
+ 'null (field omitted entirely)' => [null, false],
+ ];
+ }
+
+ // real Checkbox field to cover 'checked'/'on', the raw values an HTML checkbox submits
+ #[DataProvider('checkboxWhitelistAcceptsRawValueProvider')]
+ public function testGrindNormalizesEveryWhitelistedCheckboxRawValue(mixed $raw_value, bool $expected): void
+ {
+ $description = $this->checkboxGroupDescription();
+
+ $raw_parameters = $raw_value === null ? [] : ['enabled' => $raw_value];
+
+ $result = $this->host()->callGrind($description, $raw_parameters);
+
+ $this->assertTrue($result->isOk());
+ $this->assertSame($expected, $result->value()['enabled']);
+ }
+
+ /**
+ * Real Activities nest every field in a top-level Group: collectRawValues() narrows $raw_value only when
+ * recursing into a Group, so a bare top-level field would receive the whole array.
+ */
+ private function checkboxGroupDescription(): FormInput
+ {
+ $checkbox = $this->createRealFieldsUiFactory()->input()->field()->checkbox('Enabled', '')
+ ->withDedicatedName('enabled');
+
+ return $this->createRealFieldsUiFactory()->input()->field()->group(['enabled' => $checkbox]);
+ }
+
+ public static function checkboxWhitelistRejectsRawValueProvider(): array
+ {
+ return [
+ 'array' => [[true]],
+ 'float' => [1.0],
+ 'unrecognized string' => ['maybe'],
+ ];
+ }
+
+ /** Becomes an InvalidInputException (400), shown to the user instead of logged. */
+ #[DataProvider('checkboxWhitelistRejectsRawValueProvider')]
+ public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_value): void
+ {
+ $result = $this->host()->callGrind($this->checkboxGroupDescription(), ['enabled' => $raw_value]);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame(400, $error->getCode());
+ }
+
+ /** Same conversion, reached via Text::isClientSideValueOk(). */
+ public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStringTextValueIntoInvalidInputException(): void
+ {
+ $text = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '')
+ ->withDedicatedName('language_key');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'language_key' => $text,
+ ]);
+
+ // a bool is passed through collectRawValues() unmodified and reaches Text::withValue() as-is
+ $result = $this->host()->callGrind($description, ['language_key' => true]);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame(400, $error->getCode());
+ }
+
+ /** Must read ": ", not Group::withInput()'s generic "ui_error_in_group". */
+ public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidationFailure(): void
+ {
+ $language_key = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '')
+ ->withRequired(true)
+ ->withDedicatedName('language_key');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'language_key' => $language_key,
+ ]);
+
+ $result = $this->host()->callGrind($description, []);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame(400, $error->getCode());
+ $this->assertStringStartsWith('language_key: ', $error->getMessage());
+ $this->assertNotSame('language_key: ', $error->getMessage());
+ }
+
+ /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings: joined with commas. */
+ public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSeparatedString(): void
+ {
+ $language_keys = $this->createRealFieldsUiFactory()->input()->field()->text('Language keys', '')
+ ->withDedicatedName('language_keys');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'language_keys' => $language_keys,
+ ]);
+
+ $result = $this->host()->callGrind($description, ['language_keys' => ['de', 'fr']]);
+
+ $this->assertTrue($result->isOk());
+ $this->assertSame('de,fr', $result->value()['language_keys']);
+ }
+
+ /** The outermost group does not enforce known keys. */
+ public function testGrindSilentlyIgnoresAnUnknownKeyAtTheTopLevelOfRawParameters(): void
+ {
+ $name = $this->createRealFieldsUiFactory()->input()->field()->text('Name', '')
+ ->withDedicatedName('name');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'name' => $name,
+ ]);
+
+ $result = $this->host()->callGrind($description, [
+ 'name' => 'Foo',
+ 'some_unrelated_form_field' => 'bar',
+ ]);
+
+ $this->assertTrue($result->isOk());
+ $this->assertSame('Foo', $result->value()['name']);
+ }
+
+ /**
+ * The unknown-key message embeds the key unescaped: escaping is RendersActivityErrors::activityErrorMessage()'s
+ * job, escaping here would double-escape.
+ */
+ public function testGrindReportsUnknownNestedGroupKeysRawAndUnescaped(): void
+ {
+ $de = $this->createRealFieldsUiFactory()->input()->field()->text('German', '')
+ ->withDedicatedName('de');
+ $translations = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'de' => $de,
+ ])->withDedicatedName('translations');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'translations' => $translations,
+ ]);
+
+ $dangerous_unknown_key = '';
+
+ $result = $this->host()->callGrind($description, [
+ 'translations' => [
+ 'de' => 'Hallo',
+ $dangerous_unknown_key => 'whatever',
+ ],
+ ]);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame(400, $error->getCode());
+ $this->assertStringContainsString('Unknown key(s) for translations: ' . $dangerous_unknown_key, $error->getMessage());
+ $this->assertStringContainsString('', $error->getMessage());
+ }
+
+ public static function nonArrayNonNullGroupRawValueProvider(): array
+ {
+ return [
+ 'string' => ['not-an-array'],
+ 'int' => [42],
+ 'bool' => [true],
+ ];
+ }
+
+ /** Degrading it to an empty array would mask it as a misleading required-field error. */
+ #[DataProvider('nonArrayNonNullGroupRawValueProvider')]
+ public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray(
+ mixed $raw_value
+ ): void {
+ $de = $this->createRealFieldsUiFactory()->input()->field()->text('German', '')
+ ->withDedicatedName('de');
+ $translations = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'de' => $de,
+ ])->withDedicatedName('translations');
+ $description = $this->createRealFieldsUiFactory()->input()->field()->group([
+ 'translations' => $translations,
+ ]);
+
+ $result = $this->host()->callGrind($description, ['translations' => $raw_value]);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(InvalidInputException::class, $error);
+ $this->assertSame(400, $error->getCode());
+ $this->assertStringContainsString(
+ 'Expected an array of values (or none at all) for translations, got: ' . get_debug_type($raw_value),
+ $error->getMessage()
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php
new file mode 100644
index 000000000000..8a60ae15a3d8
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php
@@ -0,0 +1,38 @@
+grind($description, $raw_parameters);
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php
new file mode 100644
index 000000000000..eee84d02a118
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php
@@ -0,0 +1,502 @@
+createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ' de ',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['de'], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame([], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+
+ /**
+ * The Activity passes exactly the language key (no $create_missing_mo flag). Captures the raw arguments, as
+ * ->with('de') would also accept an additional one.
+ */
+ public function testFullInstallPassesOnlyTheLanguageKey(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $calls = [];
+ $setup_language->expects($this->once())
+ ->method('insertLanguageForInstallation')
+ ->willReturnCallback(static function () use (&$calls): array {
+ $calls[] = func_get_args();
+ return [];
+ });
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame([['de']], $calls);
+ }
+
+ /**
+ * A non-empty list from insertLanguageForInstallation() (migrated modules whose overlay could not be written)
+ * lands in "overlay_write_failed_language_keys"; the language still counts as installed.
+ */
+ public function testFullInstallListsOnlyTheLanguagesWhoseOverlayCouldNotBeWritten(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation')->willReturnMap([
+ ['de', ['tos', 'pilot']],
+ ['fr', []],
+ ]);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de,fr',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['de', 'fr'], $result['installed_language_keys']);
+ $this->assertSame(['de'], $result['overlay_write_failed_language_keys']);
+ }
+
+ public function testInstallLocalModeListsTheLanguageWhoseOverlayCouldNotBeWritten(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ [
+ 'de' => ['obj_id' => 1, 'status' => 'installed'],
+ 'en' => ['obj_id' => 2, 'status' => 'installed'],
+ ],
+ ['de', 'en'],
+ ['de', 'en']
+ );
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->exactly(2))->method('insertLanguageForApplyingLocalChanges')->willReturnMap([
+ ['de', []],
+ ['en', ['pilot']],
+ ]);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'en'],
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame(['de', 'en'], $result['installed_with_local_language_keys']);
+ $this->assertSame(['en'], $result['overlay_write_failed_language_keys']);
+ }
+
+ /**
+ * @param list $installed
+ */
+ #[\PHPUnit\Framework\Attributes\DataProvider('modesThatApplyTheCustomizingFile')]
+ public function testListsTheDuplicateCustomizingEntriesOfTheAffectedLanguagesOnly(string $mode, array $installed): void
+ {
+ $setup_language = $this->createSetupLanguageStub(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ [],
+ $installed
+ );
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->method('insertLanguageForInstallation')->willReturn([]);
+ $setup_language->method('insertLanguageForApplyingLocalChanges')->willReturn([]);
+ $setup_language->method('getSkippedDuplicateEntries')->willReturn([
+ 'de' => ['common#:#dup' => ['mme: dup']],
+ 'fr' => ['common#:#dup' => ['mme: dup']],
+ ]);
+
+ $result = $this->createActivity($setup_language)->perform(['language_keys' => 'de', 'mode' => $mode]);
+
+ $this->assertSame(['de: common#:#dup (mme: dup)'], $result['duplicate_customizing_entries']);
+ }
+
+ /** @return array}> */
+ public static function modesThatApplyTheCustomizingFile(): array
+ {
+ return [
+ 'install' => [InstallLanguage::MODE_INSTALL, []],
+ 'install_local' => [InstallLanguage::MODE_INSTALL_LOCAL, ['de']],
+ ];
+ }
+
+ public function testNothingIsListedWhenEveryOverlayWasWrittenOrNothingWasInstalled(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ [],
+ ['de']
+ );
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('fr')->willReturn([]);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'fr'],
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['fr'], $result['installed_language_keys']);
+ $this->assertSame([], $result['overlay_write_failed_language_keys']);
+ }
+
+ public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUnderInstallMode(): void
+ {
+ // mode "install": the already installed 'de' is left completely alone, only 'fr' is installed
+ $setup_language = $this->createSetupLanguageMock(
+ [
+ 'de' => ['obj_id' => 1, 'status' => 'installed'],
+ ],
+ [],
+ ['de']
+ );
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('fr')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('fr');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('fr');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => [' de, fr ', 'de'],
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['fr'], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame(['de'], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+
+ public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile(): void
+ {
+ // classified by "has a customizing/local file", not "newly installed"
+ $setup_language = $this->createSetupLanguageMock(
+ [],
+ ['de'],
+ []
+ );
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame(['de'], $result['installed_with_local_language_keys']);
+ $this->assertSame([], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * Mode "install" must not re-install or re-apply the local file of an already installed language.
+ */
+ public function testInstallModeIsNoOpForAlreadyInstalledLanguageEvenWithACustomFile(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ ['de'],
+ ['de']
+ );
+ $setup_language->expects($this->never())->method('checkLanguageForInstallation');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame(['de'], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+
+ public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ ['de'],
+ ['de']
+ );
+ $setup_language->expects($this->once())->method('checkLanguageForInstallation')->with('de')->willReturn(true);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->once())
+ ->method('insertLanguageForApplyingLocalChanges')
+ ->with('de');
+ // object_data bookkeeping is delegated to ilSetupLanguage::registerInstalledLanguage(), see ilSetupLanguageTest
+ $setup_language->expects($this->once())
+ ->method('registerInstalledLanguage')
+ ->with(
+ 'de',
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ ['de']
+ );
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame(['de'], $result['installed_with_local_language_keys']);
+ $this->assertSame([], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+
+ public function testInstallLocalModePassesOnlyTheLanguageKey(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ ['de'],
+ ['de']
+ );
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $calls = [];
+ $setup_language->expects($this->once())
+ ->method('insertLanguageForApplyingLocalChanges')
+ ->willReturnCallback(static function () use (&$calls): array {
+ $calls[] = func_get_args();
+ return [];
+ });
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame([['de']], $calls);
+ }
+
+ /**
+ * "install_local" on an already installed language without a local file runs its write methods, but the result
+ * must say "already installed", since nothing observable changed.
+ */
+ public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileIsReportedAsAlreadyInstalled(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ [], // no customizing/local file exists for 'de'
+ ['de']
+ );
+ $setup_language->expects($this->once())->method('checkLanguageForInstallation')->with('de')->willReturn(true);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->once())
+ ->method('insertLanguageForApplyingLocalChanges')
+ ->with('de');
+ $setup_language->expects($this->once())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame(['de'], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * "install_local" on a language that is not installed at all is a complete no-op.
+ */
+ public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->never())->method('checkLanguageForInstallation');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame([], $result['already_installed_language_keys']);
+ $this->assertSame(['de'], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * If every requested language is a no-op, perform() must not read the available/local languages or scan for
+ * invalid local files.
+ */
+ public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEntirely(): void
+ {
+ $setup_language = $this->createMock(ilSetupLanguage::class);
+ $setup_language->method('getInstalledLanguages')->willReturn(['de', 'en']);
+ $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation');
+ $setup_language->expects($this->never())->method('getLocalLanguages');
+ $setup_language->expects($this->never())->method('getInvalidLocalLanguageFiles');
+ $setup_language->expects($this->never())->method('checkLanguageForInstallation');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'en'],
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame(['de', 'en'], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+
+ /**
+ * Languages in every state at once land in their own bucket; only affected ones trigger a write (mode "install").
+ */
+ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBucket(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['de' => ['obj_id' => 1, 'status' => 'installed']],
+ ['fr'], // only 'fr' has a pending customizing/local file
+ ['de']
+ );
+ $setup_language->expects($this->exactly(2))
+ ->method('checkLanguageForInstallation')
+ ->willReturnMap([
+ ['en', true],
+ ['fr', true],
+ ]);
+ $setup_language->expects($this->exactly(2))->method('flushLanguageForInstallation');
+ $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges');
+ $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage');
+ $setup_language->expects($this->once())
+ ->method('getInvalidLocalLanguageFiles')
+ ->with(['en', 'fr']);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'en', 'fr'],
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['en'], $result['installed_language_keys']);
+ $this->assertSame(['fr'], $result['installed_with_local_language_keys']);
+ $this->assertSame(['de'], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * Mode "install_local": only insertLanguageForApplyingLocalChanges, only for already installed keys.
+ */
+ public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorrectBucket(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ [
+ 'de' => ['obj_id' => 1, 'status' => 'installed'],
+ 'en' => ['obj_id' => 2, 'status' => 'installed'],
+ ],
+ ['de'], // only 'de' has a pending customizing/local file
+ ['de', 'en']
+ );
+ $setup_language->expects($this->exactly(2))->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->exactly(2))
+ ->method('insertLanguageForApplyingLocalChanges')
+ ->with($this->logicalOr('de', 'en'));
+ $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage');
+ $setup_language->expects($this->once())
+ ->method('getInvalidLocalLanguageFiles')
+ ->with(['de', 'en']);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'en', 'fr'],
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+
+ $this->assertSame([], $result['installed_language_keys']);
+ $this->assertSame(['de'], $result['installed_with_local_language_keys']);
+ $this->assertSame(['en'], $result['already_installed_language_keys']);
+ $this->assertSame(['fr'], $result['not_installed_language_keys']);
+ }
+
+ public function testInstallingNewLanguageDoesNotUpdateAlreadyInstalledLanguages(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ [
+ 'en' => ['obj_id' => 1, 'status' => 'installed'],
+ ],
+ [],
+ ['en']
+ );
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->once())
+ ->method('registerInstalledLanguage')
+ ->with(
+ 'de',
+ ['en' => ['obj_id' => 1, 'status' => 'installed']],
+ []
+ );
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['de'], $result['installed_language_keys']);
+ $this->assertSame([], $result['installed_with_local_language_keys']);
+ $this->assertSame([], $result['already_installed_language_keys']);
+ $this->assertSame([], $result['invalid_local_language_files']);
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php
new file mode 100644
index 000000000000..d24cd578ebd5
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php
@@ -0,0 +1,334 @@
+createSetupLanguageStub(['de'], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+
+ return $this->createActivity($setup_language);
+ }
+
+ protected function validPerformParameters(): array
+ {
+ return ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL];
+ }
+
+ public function testMalformedLocalLanguageFileDoesNotPreventStandardInstallation(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], [], ['ilias_de.lang.locl']);
+ $setup_language->expects($this->once())
+ ->method('getInvalidLocalLanguageFiles')
+ ->with(['de'])
+ ->willReturn(['ilias_de.lang.locl']);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+
+ $this->assertSame(['de'], $result['installed_language_keys']);
+ $this->assertSame(['ilias_de.lang.locl'], $result['invalid_local_language_files']);
+ }
+
+ public function testInvalidLanguageFileIsReportedAsActivityError(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('xx')
+ ->willReturn(false);
+ $setup_language->method('getInstallableLanguagesForInstallation')->willReturn(['xx']);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+
+ $this->expectException(InvalidLanguageFilesException::class);
+ $this->expectExceptionCode(409);
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'xx',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+ }
+
+ public function testMixedValidAndInvalidLanguagesDoNotMutate(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->exactly(2))
+ ->method('checkLanguageForInstallation')
+ ->willReturnMap([
+ ['de', true],
+ ['xx', false],
+ ]);
+ $setup_language->method('getInstallableLanguagesForInstallation')->willReturn(['de', 'xx']);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+
+ $this->expectException(InvalidLanguageFilesException::class);
+ $this->expectExceptionCode(409);
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de,xx',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+ }
+
+ public function testUnknownLanguageIsRejectedAsInvalidInputWithoutMutation(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(false);
+ $setup_language->method('getInstallableLanguagesForInstallation')->willReturn(['de']);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+
+ try {
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'qq',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+ $this->fail('InvalidInputException expected');
+ } catch (InvalidInputException $e) {
+ $this->assertSame(400, $e->getCode());
+ $this->assertSame('Unknown languages: qq', $e->getMessage());
+ }
+ }
+
+ /** The unknown language decides: the caller has to fix its input first. Only unknown keys are named. */
+ public function testUnknownAndInvalidLanguagesTogetherAreRejectedAsInvalidInput(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturnMap([
+ ['de', true],
+ ['xx', false],
+ ['qq', false],
+ ]);
+ $setup_language->method('getInstallableLanguagesForInstallation')->willReturn(['de', 'xx']);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+
+ try {
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de,xx,qq',
+ 'mode' => InstallLanguage::MODE_INSTALL,
+ ]);
+ $this->fail('InvalidInputException expected');
+ } catch (InvalidInputException $e) {
+ $this->assertSame(400, $e->getCode());
+ $this->assertSame('Unknown languages: qq', $e->getMessage());
+ }
+ }
+
+ /**
+ * "install_local" merges the shipped files too: an invalid one of an installed language rejects the request (409)
+ * before anything is written, languages that are not installed are not checked.
+ */
+ public function testInstallLocalModeWithAnInvalidLanguageFileIsReportedAsActivityErrorWithoutWriting(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de', 'fr']);
+ $setup_language->expects($this->exactly(2))
+ ->method('checkLanguageForInstallation')
+ ->willReturnMap([
+ ['de', false],
+ ['fr', true],
+ ]);
+ $setup_language->method('getInstallableLanguagesForInstallation')->willReturn(['fr']);
+ $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ try {
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de,fr,qq',
+ 'mode' => InstallLanguage::MODE_INSTALL_LOCAL,
+ ]);
+ $this->fail('InvalidLanguageFilesException expected');
+ } catch (InvalidLanguageFilesException $e) {
+ $this->assertSame(409, $e->getCode());
+ $this->assertSame(['de'], $e->getLanguageKeys());
+ }
+ }
+
+ public function testMissingModeParameterIsRejected(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform([
+ 'language_keys' => 'de',
+ ]);
+ }
+
+ public function testInvalidModeValueIsRejected(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform([
+ 'language_keys' => 'de',
+ 'mode' => 'foo',
+ ]);
+ }
+
+ public function testInputDescriptionUsesNamedLanguageKeysAndModeFields(): void
+ {
+ $text = $this->createMock(Text::class);
+ $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf();
+ $text->expects($this->once())
+ ->method('withDedicatedName')
+ ->with('language_keys')
+ ->willReturnSelf();
+
+ $select = $this->createMock(Select::class);
+ $select->expects($this->once())->method('withRequired')->with(true)->willReturnSelf();
+ $select->expects($this->once())
+ ->method('withDedicatedName')
+ ->with('mode')
+ ->willReturnSelf();
+
+ $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class);
+ $field->expects($this->once())->method('text')->with(
+ 'Language keys',
+ 'Comma-separated list of language keys, e.g. de, fr, it.'
+ )->willReturn($text);
+ $field->expects($this->once())->method('select')->with(
+ 'Mode',
+ [
+ InstallLanguage::MODE_INSTALL => 'Install',
+ InstallLanguage::MODE_INSTALL_LOCAL => 'Install local',
+ ],
+ self::callback(static fn(mixed $value): bool => is_string($value))
+ )->willReturn($select);
+
+ $group = $this->createStub(\ILIAS\UI\Component\Input\Field\Section::class);
+ $field->expects($this->once())
+ ->method('section')
+ ->with(['language_keys' => $text, 'mode' => $select], $this->anything())
+ ->willReturn($group);
+
+ $activity = $this->createActivity(
+ $this->createSetupLanguageStub([], [], [])
+ );
+
+ $this->assertSame($group, $activity->getInputDescription($field));
+ }
+
+ public static function invalidLanguageKeysProvider(): array
+ {
+ return [
+ 'empty (only whitespace/commas)' => [
+ ['language_keys' => ' , ', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ 'nested array value' => [
+ ['language_keys' => ['de', ['fr']], 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ // format validation of ParsesLanguageKeyList::toLanguageKeyList()
+ 'three letters' => [
+ ['language_keys' => 'deu', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ 'one letter' => [
+ ['language_keys' => 'd', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ 'uppercase' => [
+ ['language_keys' => 'DE', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ 'contains a digit' => [
+ ['language_keys' => 'de1', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ 'contains a hyphen' => [
+ ['language_keys' => 'de-at', 'mode' => InstallLanguage::MODE_INSTALL],
+ ],
+ ];
+ }
+
+ #[DataProvider('invalidLanguageKeysProvider')]
+ public function testInvalidLanguageKeysAreRejected(array $parameters): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform($parameters);
+ }
+
+ /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */
+ public function testNonArrayParametersAreRejected(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform('not-an-array');
+ }
+
+ /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */
+ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndInstallsEachOne(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->exactly(2))->method('flushLanguageForInstallation');
+ $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation');
+
+ $result = $this->createActivity($setup_language, $rbac)->maybePerformAs(
+ $this->createRealFieldsUiFactory()->input(),
+ 6,
+ ['language_keys' => ['de', 'fr'], 'mode' => InstallLanguage::MODE_INSTALL]
+ );
+
+ $this->assertTrue($result->isOk());
+ $this->assertSame(['de', 'fr'], $result->value()['installed_language_keys']);
+ }
+
+ public function testPermissionDeniedBeforePerform(): void
+ {
+ $rbac = $this->createMock(\ilRbacSystem::class);
+ $rbac->expects($this->once())
+ ->method('checkAccessOfUser')
+ ->with(
+ 6,
+ 'write',
+ $this->anything()
+ )
+ ->willReturn(false);
+
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation');
+ $result = $this->createActivity(
+ $setup_language,
+ $rbac
+ )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]);
+
+ $this->assertTrue($result->isError());
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php
new file mode 100644
index 000000000000..1c328cc08155
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php
@@ -0,0 +1,90 @@
+rbacOf($rbac ?? $this->createStub(\ilRbacSystem::class)),
+ $setup_language,
+ $this->languageFolder()
+ );
+ }
+
+ protected function createSetupLanguageMock(
+ array $available_languages,
+ array $local_language_keys,
+ array $installed_language_keys,
+ array $invalid_local_language_files = []
+ ): MockObject&ilSetupLanguage {
+ $setup_language = $this->createMock(ilSetupLanguage::class);
+ $this->configureSetupLanguage(
+ $setup_language,
+ $available_languages,
+ $local_language_keys,
+ $installed_language_keys,
+ $invalid_local_language_files
+ );
+
+ return $setup_language;
+ }
+
+ /** For call sites without ->expects(): a plain mock there triggers PHPUnit's "no expectations" notice. */
+ protected function createSetupLanguageStub(
+ array $available_languages,
+ array $local_language_keys,
+ array $installed_language_keys,
+ array $invalid_local_language_files = []
+ ): Stub&ilSetupLanguage {
+ $setup_language = $this->createStub(ilSetupLanguage::class);
+ $this->configureSetupLanguage(
+ $setup_language,
+ $available_languages,
+ $local_language_keys,
+ $installed_language_keys,
+ $invalid_local_language_files
+ );
+
+ return $setup_language;
+ }
+
+ private function configureSetupLanguage(
+ ilSetupLanguage&Stub $setup_language,
+ array $available_languages,
+ array $local_language_keys,
+ array $installed_language_keys,
+ array $invalid_local_language_files
+ ): void {
+ $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages);
+ $setup_language->method('getLocalLanguages')->willReturn($local_language_keys);
+ $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys);
+ $setup_language->method('getInvalidLocalLanguageFiles')->willReturn($invalid_local_language_files);
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php
new file mode 100644
index 000000000000..0b207f1d550b
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php
@@ -0,0 +1,91 @@
+}>
+ */
+ public static function setupActivities(): array
+ {
+ return [
+ 'install' => [InstallLanguage::class],
+ 'update' => [UpdateLanguage::class],
+ ];
+ }
+
+ /**
+ * @param class-string $class
+ */
+ #[DataProvider('setupActivities')]
+ public function testForSetupActivityRefusesIsAllowedToPerform(string $class): void
+ {
+ $activity = $class::forSetup($this->createStub(\ilSetupLanguage::class));
+
+ $this->expectException(\LogicException::class);
+ $activity->isAllowedToPerform(6, []);
+ }
+
+ public function testWebActivityAsksRbacSystemOnlyInIsAllowedToPerform(): void
+ {
+ $system = $this->createMock(\ilRbacSystem::class);
+ $system->expects(self::once())->method('checkAccessOfUser')
+ ->with(6, 'write', 77)->willReturn(true);
+ $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class);
+ $rbac->expects(self::once())->method('system')->willReturn($system);
+
+ $activity = new InstallLanguage(
+ $rbac,
+ $this->createStub(\ilSetupLanguage::class),
+ $this->languageFolder(77)
+ );
+
+ self::assertTrue($activity->isAllowedToPerform(6, []));
+ }
+
+ public function testForSetupActivityStillPerformsWithoutRbac(): void
+ {
+ $setup_language = $this->createStub(\ilSetupLanguage::class);
+ $setup_language->method('getInstalledLanguages')->willReturn(['de']);
+
+ $result = UpdateLanguage::forSetup($setup_language)->perform(['language_keys' => ['fr']]);
+
+ self::assertSame(
+ [
+ 'updated_language_keys' => [],
+ 'not_installed_language_keys' => ['fr'],
+ 'overlay_write_failed_language_keys' => [],
+ 'invalid_markup_customizing_entries' => [],
+ 'duplicate_customizing_entries' => [],
+ ],
+ $result
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php
new file mode 100644
index 000000000000..977d3cd11a60
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php
@@ -0,0 +1,345 @@
+createMock(\ilRbacSystem::class);
+ $rbac->expects($this->never())->method('checkAccessOfUser');
+
+ $activity = new LanguageActivityThrowingInputDescriptionTestDouble(
+ $this->rbacOf($rbac),
+ $this->languageFolder()
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []);
+
+ $this->assertTrue($result->isError());
+ $error = $result->error();
+ $this->assertInstanceOf(\RuntimeException::class, $error);
+ $this->assertSame(500, $error->getCode());
+ $this->assertSame('The activity could not be performed.', $error->getMessage());
+ $this->assertInstanceOf(\RuntimeException::class, $error->getPrevious());
+ $this->assertSame('simulated failure while building the input description', $error->getPrevious()->getMessage());
+ }
+
+ /** A non-\Exception \Throwable (\TypeError) is masked as well. */
+ public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void
+ {
+ $activity = new LanguageActivityThrowingInputDescriptionTestDouble(
+ $this->rbacOf($this->createMock(\ilRbacSystem::class)),
+ $this->languageFolder(),
+ throw_type_error: true,
+ );
+
+ $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [])->error();
+
+ $this->assertInstanceOf(\RuntimeException::class, $error);
+ $this->assertSame(500, $error->getCode());
+ $this->assertStringNotContainsString('TypeError', $error->getMessage());
+ $this->assertInstanceOf(\TypeError::class, $error->getPrevious());
+ }
+
+ /** grind() throws on a defect (FormInput without InputInternal); maybePerformAs() must mask it as 500. */
+ public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void
+ {
+ $activity = new LanguageActivityPerformEchoTestDouble(
+ $this->rbacOf($this->createMock(\ilRbacSystem::class)),
+ $this->languageFolder(),
+ $this->createMock(FormInput::class),
+ );
+
+ $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [])->error();
+
+ $this->assertSame(500, $error->getCode());
+ $this->assertInstanceOf(\LogicException::class, $error->getPrevious());
+ }
+
+ /** @return array */
+ public static function unknownUserIdProvider(): array
+ {
+ return ['zero' => [0], 'negative' => [-1], 'anonymous' => [null]];
+ }
+
+ /** No RBAC lookup for a non-user: the anonymous user (null: ANONYMOUS_USER_ID) must not pass on its roles. */
+ #[DataProvider('unknownUserIdProvider')]
+ public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(?int $usr_id): void
+ {
+ if (!defined('ANONYMOUS_USER_ID')) {
+ define('ANONYMOUS_USER_ID', 13);
+ }
+ $usr_id ??= ANONYMOUS_USER_ID;
+
+ $rbac_system = $this->createMock(\ilRbacSystem::class);
+ $rbac_system->expects($this->never())->method('checkAccessOfUser');
+
+ $activity = new LanguageActivityPerformEchoTestDouble(
+ $this->rbacOf($rbac_system),
+ $this->languageFolder()
+ );
+
+ $this->assertFalse($activity->isAllowedToPerform($usr_id, []));
+ }
+
+ public function testMaybePerformAsAnswers403ForANonUserAndNeverPerforms(): void
+ {
+ $rbac_system = $this->createMock(\ilRbacSystem::class);
+ $rbac_system->expects($this->never())->method('checkAccessOfUser');
+
+ $activity = new LanguageActivityPerformEchoTestDouble(
+ $this->rbacOf($rbac_system),
+ $this->languageFolder()
+ );
+
+ $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 0, [])->error();
+
+ $this->assertSame(403, $error->getCode());
+ }
+
+ /**
+ * perform() gets the usr_id of maybePerformAs(), not the spoofed one from normalizeParameters(). Needs a
+ * dedicated double, as no real Activity collects a 'usr_id' field.
+ */
+ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedFormKey(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $activity = new LanguageActivityPerformEchoTestDouble(
+ $this->rbacOf($rbac),
+ $this->languageFolder()
+ );
+
+ $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 42, []);
+
+ $this->assertFalse($result->isError());
+ $parameters = $result->value();
+ // the trusted usr_id (42) wins over the spoofed one from normalizeParameters()
+ $this->assertSame(42, $parameters['usr_id']);
+ // a non-colliding key survives the merge
+ $this->assertSame('form', $parameters['source']);
+ }
+
+ /**
+ * Skipped entries are listed with their markup, language-neutral and without duplicates; only the requested
+ * languages appear, in requested order.
+ */
+ public function testInvalidMarkupCustomizingEntriesAppendTheMarkupConcernedToEachEntry(): void
+ {
+ $policy = new TranslationMarkupPolicy();
+ $skipped = [
+ 'de' => [
+ 'tos#:#x' => $policy->findViolations(''),
+ 'tos#:#plain' => [],
+ ],
+ 'en' => ['tos#:#y' => $policy->findViolations('')],
+ 'fr' => ['tos#:#z' => $policy->findViolations('')],
+ ];
+ $activity = new LanguageActivityInvalidMarkupTestDouble(
+ $this->rbacOf($this->createStub(\ilRbacSystem::class)),
+ $this->languageFolder()
+ );
+
+ $this->assertSame(
+ ['en: tos#:#y: &"quoted"';
+
+ try {
+ $this->createActivity(repository: $repository)
+ ->perform(['language_key' => $dangerous_key, 'enabled' => true]);
+ $this->fail('Expected an InvalidInputException to be thrown.');
+ } catch (InvalidInputException $e) {
+ $this->assertStringContainsString($dangerous_key, $e->getMessage());
+ $this->assertStringNotContainsString('<script>', $e->getMessage());
+ $this->assertStringNotContainsString('"', $e->getMessage());
+ }
+ }
+
+ /** Same rejection end-to-end: it passes grind() and the permission check, perform() rejects it. */
+ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckAndNeverTouchesSettings(): void
+ {
+ $rbac = $this->createMock(\ilRbacSystem::class);
+ $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true);
+
+ $repository = $this->createMock(RepositoryProvider::class);
+ $repository->expects($this->never())->method('isPageTranslationEnabled');
+ $repository->expects($this->never())->method('setPageTranslationEnabled');
+
+ $result = $this->createActivity(rbac_system: $rbac, repository: $repository)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'xx', 'enabled' => true]);
+
+ $this->assertTrue($result->isError());
+ $this->assertInstanceOf(InvalidInputException::class, $result->error());
+ $this->assertSame(400, $result->error()->getCode());
+ }
+
+ /** A language_key with surrounding whitespace resolves to the same setting key as via perform(). */
+ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $repository = $this->createMock(RepositoryProvider::class);
+ $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false);
+ $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true);
+
+ $result = $this->createActivity(rbac_system: $rbac, repository: $repository)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' de ', 'enabled' => true]);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame('de', $result->value()['language_key']);
+ }
+
+ public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFieldAndAnEnabledCheckbox(): void
+ {
+ $text = $this->createMock(Text::class);
+ $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf();
+ $text->expects($this->once())->method('withDedicatedName')->with('language_key')->willReturnSelf();
+
+ $checkbox = $this->createMock(Checkbox::class);
+ $checkbox->expects($this->once())
+ ->method('withDedicatedName')
+ ->with('enabled')
+ ->willReturnSelf();
+
+ $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class);
+ $field->expects($this->once())
+ ->method('text')
+ ->with(
+ 'Language key',
+ 'Language key the page translation setting applies to, e.g. de, fr, it.'
+ )
+ ->willReturn($text);
+ $field->expects($this->once())
+ ->method('checkbox')
+ ->with(
+ 'Enabled',
+ 'Whether page translation should be enabled for this language.'
+ )
+ ->willReturn($checkbox);
+
+ $group = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class);
+ $field->expects($this->once())
+ ->method('section')
+ ->with(['language_key' => $text, 'enabled' => $checkbox], $this->anything())
+ ->willReturn($group);
+
+ $activity = $this->createActivity();
+
+ $this->assertSame($group, $activity->getInputDescription($field));
+ }
+
+ /** Real DescriptionFactory: a mocked with([...]) would accept the fields in any order. */
+ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): void
+ {
+ $activity = $this->createActivity();
+
+ $description = $activity->getOutputDescription(new DescriptionFactory());
+
+ $field_names = [];
+ foreach ($description->getFields() as $field) {
+ $field_names[] = $field->getName();
+ }
+
+ $this->assertSame(['language_key', 'enabled', 'changed'], $field_names);
+ }
+
+ private function createActivity(
+ ?\ilRbacSystem $rbac_system = null,
+ (RepositoryProvider&MockObject)|null $repository = null,
+ int $language_folder_ref_id = 0,
+ array $installed_languages = ['de', 'fr'],
+ ): SetLanguageTranslationEnabled {
+ $repository ??= $this->createMock(RepositoryProvider::class);
+ $repository->method('getInstalledLanguages')->willReturn($installed_languages);
+
+ return new SetLanguageTranslationEnabled(
+ $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)),
+ $this->languageFolder($language_folder_ref_id),
+ $repository,
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageAmbiguityTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageAmbiguityTest.php
new file mode 100644
index 000000000000..f6637b353598
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageAmbiguityTest.php
@@ -0,0 +1,203 @@
+ [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'de'],
+ ];
+ $factory_calls = [];
+ $obj_language_factory = static function (int $id) use (&$factory_calls): never {
+ $factory_calls[] = $id;
+ throw new \LogicException('must never be called for an ambiguous title');
+ };
+
+ $activity = $this->createActivity($lng_objects, $obj_language_factory);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/"de"/');
+
+ try {
+ $activity->perform(['language_keys' => 'de']);
+ } finally {
+ $this->assertSame([], $factory_calls);
+ }
+ }
+
+ /**
+ * The exception message embeds the ambiguous title unescaped: escaping is
+ * RendersActivityErrors::activityErrorMessage()'s job.
+ */
+ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void
+ {
+ $ambiguous_title = 'de';
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => $ambiguous_title],
+ ['obj_id' => 2, 'title' => $ambiguous_title],
+ ];
+ $obj_language_factory = static function (int $id): never {
+ throw new \LogicException('must never be called for an ambiguous title');
+ };
+
+ $activity = $this->createActivity($lng_objects, $obj_language_factory);
+
+ try {
+ $activity->perform(['language_keys' => $ambiguous_title]);
+ $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.');
+ } catch (AmbiguousLanguageTitleException $e) {
+ $this->assertStringContainsString($ambiguous_title, $e->getMessage());
+ $this->assertStringNotContainsString('<script>', $e->getMessage());
+ $this->assertStringNotContainsString('"', $e->getMessage());
+ }
+ }
+
+ /**
+ * Via maybePerformAs() the exception must surface as a Result\Error, and the object factory stays untouched.
+ */
+ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'de'],
+ ];
+ $factory_calls = [];
+ $obj_language_factory = static function (int $id) use (&$factory_calls): never {
+ $factory_calls[] = $id;
+ throw new \LogicException('must never be called for an ambiguous title');
+ };
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertTrue($result->isError());
+ $this->assertInstanceOf(\RuntimeException::class, $result->error());
+ $this->assertSame([], $factory_calls);
+ }
+
+ /**
+ * An ambiguous title must not affect a unique language key in the same request.
+ */
+ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void
+ {
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'de'],
+ ['obj_id' => 3, 'title' => 'fr'],
+ ];
+ $fr = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true);
+ $obj_language_factory = static function (int $id) use ($fr): FakeLanguageObject {
+ if ($id === 3) {
+ return $fr;
+ }
+
+ throw new \LogicException('must never be called for the ambiguous "de" title');
+ };
+
+ $activity = $this->createActivity($lng_objects, $obj_language_factory);
+
+ $result = $activity->perform(['language_keys' => 'fr']);
+
+ $this->assertSame(['fr'], $result['uninstalled_language_keys']);
+ $this->assertSame(1, $fr->uninstallCallCount());
+ }
+
+ /**
+ * The ambiguity check runs for all requested keys before any uninstall() call.
+ */
+ public function testAnUnambiguousKeyBeforeAnAmbiguousOneIsNeverUninstalledOnceTheWholeRequestIsRejected(): void
+ {
+ $de = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true);
+
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'fr'],
+ ['obj_id' => 3, 'title' => 'fr'],
+ ];
+ $factory_calls = [];
+ $obj_language_factory = static function (int $id) use (&$factory_calls, $de): FakeLanguageObject {
+ $factory_calls[] = $id;
+ if ($id === 1) {
+ return $de;
+ }
+
+ throw new \LogicException('must never be called for the ambiguous "fr" title');
+ };
+
+ $activity = $this->createActivity($lng_objects, $obj_language_factory);
+
+ try {
+ // 'de' is listed before the ambiguous 'fr' on purpose: an inline check would uninstall 'de' first
+ $activity->perform(['language_keys' => 'de,fr']);
+ $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.');
+ } catch (AmbiguousLanguageTitleException $e) {
+ $this->assertStringContainsString('"fr"', $e->getMessage());
+ // 'de' was never resolved, let alone uninstalled
+ $this->assertSame([], $factory_calls);
+ $this->assertSame(0, $de->uninstallCallCount());
+ }
+ }
+
+ /**
+ * Same, end-to-end through maybePerformAs().
+ */
+ public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $de = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true);
+
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'fr'],
+ ['obj_id' => 3, 'title' => 'fr'],
+ ];
+ $obj_language_factory = static function (int $id) use ($de): FakeLanguageObject {
+ if ($id === 1) {
+ return $de;
+ }
+
+ throw new \LogicException('must never be called for the ambiguous "fr" title');
+ };
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]);
+
+ $this->assertTrue($result->isError());
+ $this->assertInstanceOf(AmbiguousLanguageTitleException::class, $result->error());
+ $this->assertSame(0, $de->uninstallCallCount());
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php
new file mode 100644
index 000000000000..2153a3a876ae
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php
@@ -0,0 +1,412 @@
+buildFakeLanguageWorld(['de' => []]);
+
+ return $this->createActivity($lng_objects, $obj_language_factory);
+ }
+
+ protected function validPerformParameters(): array
+ {
+ return ['language_keys' => 'de'];
+ }
+
+ public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePerformAs(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $lng_objects = static fn(): array => [
+ ['obj_id' => 1, 'title' => 'de'],
+ ['obj_id' => 2, 'title' => 'de'],
+ ];
+ $obj_language_factory = static function (int $id): never {
+ throw new \LogicException('must never be called for an ambiguous title');
+ };
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertTrue($result->isError());
+ $this->assertInstanceOf(\RuntimeException::class, $result->error());
+ }
+
+ /**
+ * @param array}> $language_objects keyed by language key
+ * @return array{0: array, 1: \Closure, 2: \Closure}
+ * [obj id => fake object, lng_objects closure, obj_language_factory closure]
+ */
+ private function buildFakeLanguageWorld(array $language_objects): array
+ {
+ $lng_objects_list = [];
+ $fakes_by_obj_id = [];
+ $obj_id = 1;
+
+ foreach ($language_objects as $language_key => $flags) {
+ $lng_objects_list[] = ['obj_id' => $obj_id, 'title' => $language_key];
+ $fakes_by_obj_id[$obj_id] = new FakeLanguageObject(
+ is_system_language: $flags['system'] ?? false,
+ is_user_language: $flags['user'] ?? false,
+ is_installed: $flags['installed'] ?? true,
+ uninstall_return_value: $flags['uninstall_return'] ?? 'uninstalled',
+ unwritten_overlay_modules: $flags['unwritten_overlay_modules'] ?? [],
+ );
+ $obj_id++;
+ }
+
+ $lng_objects = static fn(): array => $lng_objects_list;
+ $obj_language_factory = static fn(int $id): FakeLanguageObject => $fakes_by_obj_id[$id];
+
+ return [$fakes_by_obj_id, $lng_objects, $obj_language_factory];
+ }
+
+ public function testInstalledOrdinaryLanguageIsUninstalled(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame(['de'], $result['uninstalled_language_keys']);
+ $this->assertSame([], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['overlay_write_failed_language_keys']);
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ }
+
+ public function testSystemLanguageIsNeverUninstalled(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['system' => true],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame([], $result['uninstalled_language_keys']);
+ $this->assertSame(['de'], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame(0, $fakes[1]->uninstallCallCount());
+ }
+
+ public function testLanguageCurrentlyInUseIsNeverUninstalled(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['user' => true],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame([], $result['uninstalled_language_keys']);
+ $this->assertSame([], $result['system_language_keys']);
+ $this->assertSame(['de'], $result['user_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ $this->assertSame(0, $fakes[1]->uninstallCallCount());
+ }
+
+ /** isSystemLanguage() takes priority over "in use" when both flags are set. */
+ public function testSystemLanguageTakesPriorityOverUserLanguage(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['system' => true, 'user' => true],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame(['de'], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ }
+
+ public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalled(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['installed' => false],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame([], $result['uninstalled_language_keys']);
+ $this->assertSame([], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ $this->assertSame(['de'], $result['not_installed_language_keys']);
+ $this->assertSame(0, $fakes[1]->uninstallCallCount());
+ }
+
+ /** uninstall() returning '' is the authority: reported as not_installed_language_keys, never as uninstalled. */
+ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['uninstall_return' => ''],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame([], $result['uninstalled_language_keys']);
+ $this->assertSame([], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ $this->assertSame(['de'], $result['not_installed_language_keys']);
+ // uninstall() must still have been attempted
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ }
+
+ /** A module whose PO/MO overlay could not be removed is reported in addition to the successful uninstall. */
+ public function testOverlayWriteFailureIsReportedAlongsideTheSuccessfulUninstall(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['unwritten_overlay_modules' => ['pilot']],
+ 'fr' => [],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform(['language_keys' => 'de,fr']);
+
+ $this->assertSame(['de', 'fr'], $result['uninstalled_language_keys']);
+ $this->assertSame(['de'], $result['overlay_write_failed_language_keys']);
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ }
+
+ /** A language the guards of uninstall() rejected has no overlay state to report. */
+ public function testNoOverlayWriteFailureIsReportedForALanguageUninstallRejected(): void
+ {
+ [, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => ['uninstall_return' => '', 'unwritten_overlay_modules' => ['pilot']],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform(['language_keys' => 'de']);
+
+ $this->assertSame(['de'], $result['not_installed_language_keys']);
+ $this->assertSame([], $result['overlay_write_failed_language_keys']);
+ }
+
+ /** A key without a "lng" object is not installed, and the object factory is never invoked for it. */
+ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingTheObjectFactory(): void
+ {
+ $lng_objects = static fn(): array => [];
+ $factory_calls = [];
+ $obj_language_factory = static function (int $id) use (&$factory_calls): FakeLanguageObject {
+ $factory_calls[] = $id;
+ throw new \LogicException('must never be called for an unknown language key');
+ };
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => 'xx',
+ ]);
+
+ $this->assertSame([], $result['uninstalled_language_keys']);
+ $this->assertSame([], $result['system_language_keys']);
+ $this->assertSame([], $result['user_language_keys']);
+ $this->assertSame(['xx'], $result['not_installed_language_keys']);
+ $this->assertSame([], $factory_calls);
+ }
+
+ /**
+ * Languages in every state at once land in their own bucket; only the uninstalled one calls uninstall().
+ */
+ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ 'en' => ['system' => true],
+ 'fr' => ['user' => true],
+ 'it' => ['installed' => false],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => ['de', 'en', 'fr', 'it', 'xx'],
+ ]);
+
+ $this->assertSame(['de'], $result['uninstalled_language_keys']);
+ $this->assertSame(['en'], $result['system_language_keys']);
+ $this->assertSame(['fr'], $result['user_language_keys']);
+ $this->assertSame(['it', 'xx'], $result['not_installed_language_keys']);
+
+ $this->assertSame(1, $fakes[1]->uninstallCallCount(), 'de must be uninstalled');
+ $this->assertSame(0, $fakes[2]->uninstallCallCount(), 'system language must never be uninstalled');
+ $this->assertSame(0, $fakes[3]->uninstallCallCount(), 'in-use language must never be uninstalled');
+ $this->assertSame(0, $fakes[4]->uninstallCallCount(), 'not-installed language must never be uninstalled');
+ }
+
+ public function testDuplicateLanguageKeysAcrossStringAndArrayAreDeduplicated(): void
+ {
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([
+ 'language_keys' => [' de, de ', 'de'],
+ ]);
+
+ $this->assertSame(['de'], $result['uninstalled_language_keys']);
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ }
+
+ public static function invalidLanguageKeysProvider(): array
+ {
+ return [
+ 'missing language_keys key' => [[]],
+ 'empty (only whitespace/commas)' => [['language_keys' => ' , ']],
+ 'nested array value' => [['language_keys' => ['de', ['fr']]]],
+ 'three letters' => [['language_keys' => 'deu']],
+ 'one letter' => [['language_keys' => 'd']],
+ 'uppercase' => [['language_keys' => 'DE']],
+ 'contains a digit' => [['language_keys' => 'de1']],
+ 'contains a hyphen' => [['language_keys' => 'de-at']],
+ ];
+ }
+
+ #[DataProvider('invalidLanguageKeysProvider')]
+ public function testInvalidLanguageKeysAreRejected(array $parameters): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters);
+ }
+
+ /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */
+ public function testNonArrayParametersAreRejected(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array');
+ }
+
+ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField(): void
+ {
+ $text = $this->createMock(Text::class);
+ $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf();
+ $text->expects($this->once())
+ ->method('withDedicatedName')
+ ->with('language_keys')
+ ->willReturnSelf();
+
+ $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class);
+ $field->expects($this->once())->method('text')->with(
+ 'Language keys',
+ 'Comma-separated list of language keys, e.g. de, fr, it.'
+ )->willReturn($text);
+ // no mode parameter: the select field must never be built
+ $field->expects($this->never())->method('select');
+
+ $group = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class);
+ $field->expects($this->once())
+ ->method('section')
+ ->with(['language_keys' => $text], $this->anything())
+ ->willReturn($group);
+
+ $activity = $this->createActivity(
+ static fn(): array => [],
+ static fn(int $id) => null
+ );
+
+ $this->assertSame($group, $activity->getInputDescription($field));
+ }
+
+ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): void
+ {
+ $rbac = $this->createMock(\ilRbacSystem::class);
+ $rbac->expects($this->once())
+ ->method('checkAccessOfUser')
+ ->with(6, 'write', $this->anything())
+ ->willReturn(false);
+
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ ]);
+ $result = $this->createActivity(
+ $lng_objects,
+ $obj_language_factory,
+ $rbac
+ )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertTrue($result->isError());
+ $this->assertSame(0, $fakes[1]->uninstallCallCount());
+ }
+
+ public function testPermissionGrantedPerformsAndReturnsOkResult(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ ]);
+
+ $result = $this->createActivity(
+ $lng_objects,
+ $obj_language_factory,
+ $rbac
+ )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame(['de'], $result->value()['uninstalled_language_keys']);
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ }
+
+ /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */
+ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUninstallsEachOne(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([
+ 'de' => [],
+ 'fr' => [],
+ ]);
+
+ $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame(['de', 'fr'], $result->value()['uninstalled_language_keys']);
+ $this->assertSame(1, $fakes[1]->uninstallCallCount());
+ $this->assertSame(1, $fakes[2]->uninstallCallCount());
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTestFixture.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTestFixture.php
new file mode 100644
index 000000000000..a0e4821b171a
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTestFixture.php
@@ -0,0 +1,43 @@
+rbacOf($rbac ?? $this->createStub(\ilRbacSystem::class)),
+ $this->languageFolder(),
+ $repository ?? $this->languageObjectProvider($lng_objects, $obj_language_factory)
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php
new file mode 100644
index 000000000000..5b5667b1ba75
--- /dev/null
+++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php
@@ -0,0 +1,493 @@
+createSetupLanguageStub([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+
+ return $this->createActivity($setup_language);
+ }
+
+ protected function validPerformParameters(): array
+ {
+ return ['language_keys' => 'de'];
+ }
+
+ /**
+ * UpdateLanguage passes exactly the language key. Captures the raw arguments, as ->with('de') would also
+ * accept an additional one.
+ */
+ public function testPassesOnlyTheLanguageKeyToInsertLanguageForInstallation(): void
+ {
+ $setup_language = $this->createSetupLanguageStub([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $calls = [];
+ $setup_language->method('insertLanguageForInstallation')->willReturnCallback(
+ static function () use (&$calls): array {
+ $calls[] = func_get_args();
+ return [];
+ }
+ );
+
+ $this->createActivity($setup_language)->perform(['language_keys' => 'de']);
+
+ $this->assertSame([['de']], $calls);
+ }
+
+ /**
+ * A non-empty return of insertLanguageForInstallation() (migrated modules whose overlay could not be written)
+ * lists the language, which still counts as updated.
+ */
+ public function testListsOnlyTheLanguagesWhoseOverlayCouldNotBeWritten(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de', 'fr']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation')->willReturnMap([
+ ['de', []],
+ ['fr', ['pilot']],
+ ]);
+
+ $result = $this->createActivity($setup_language)->perform(['language_keys' => 'de,fr,it']);
+
+ $this->assertSame(['de', 'fr'], $result['updated_language_keys']);
+ $this->assertSame(['it'], $result['not_installed_language_keys']);
+ $this->assertSame(['fr'], $result['overlay_write_failed_language_keys']);
+ }
+
+ public function testListsTheDuplicateCustomizingEntriesOfTheUpdatedLanguagesOnly(): void
+ {
+ $setup_language = $this->createSetupLanguageStub([], [], ['de', 'fr']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->method('insertLanguageForInstallation')->willReturn([]);
+ $setup_language->method('getSkippedDuplicateEntries')->willReturn([
+ 'de' => ['common#:#dup' => ['mme: dup']],
+ 'fr' => ['common#:#dup' => ['mme: dup']],
+ ]);
+
+ $result = $this->createActivity($setup_language)->perform(['language_keys' => 'de']);
+
+ $this->assertSame(['de: common#:#dup (mme: dup)'], $result['duplicate_customizing_entries']);
+ }
+
+ public function testNothingIsListedWhenEveryOverlayWasWritten(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de')->willReturn([]);
+
+ $result = $this->createActivity($setup_language)->perform(['language_keys' => 'de']);
+
+ $this->assertSame([], $result['overlay_write_failed_language_keys']);
+ }
+
+ /**
+ * The update also re-applies the plugin language files, once and only for languages actually updated.
+ */
+ public function testReappliesThePluginLanguageFilesForTheUpdatedLanguagesOnly(): void
+ {
+ $setup_language = $this->createSetupLanguageStub([], [], ['de', 'fr']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $repository = $this->createMock(RepositoryProvider::class);
+ $repository->expects($this->once())->method('refreshPlugins')->with(['de', 'fr']);
+
+ $this->createActivity($setup_language, null, $repository)->perform(['language_keys' => 'de,fr,it']);
+ }
+
+ public function testNothingUpdatedNothingToReapply(): void
+ {
+ $repository = $this->createMock(RepositoryProvider::class);
+ $repository->expects($this->never())->method('refreshPlugins');
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []), null, $repository)
+ ->perform(['language_keys' => 'de']);
+ }
+
+ /** The Setup instance has no provider: the plugin objectives apply the plugin language files there. */
+ public function testTheSetupInstanceUpdatesWithoutRefreshingPlugins(): void
+ {
+ $setup_language = $this->createSetupLanguageStub([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+
+ $result = UpdateLanguage::forSetup($setup_language)->perform(['language_keys' => 'de']);
+
+ $this->assertSame(['de'], $result['updated_language_keys']);
+ }
+
+ public function testSingleAlreadyInstalledLanguageIsRefreshed(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('registerInstalledLanguage')->with(
+ 'de',
+ [],
+ []
+ );
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ' de ',
+ ]);
+
+ $this->assertSame(['de'], $result['updated_language_keys']);
+ $this->assertSame([], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * A language that is not installed is a complete no-op, not even checkLanguageForInstallation may run;
+ * InstallLanguage installs it.
+ */
+ public function testSingleNotInstalledLanguageIsCompleteNoOp(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], []);
+ $setup_language->expects($this->never())->method('checkLanguageForInstallation');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ ]);
+
+ $this->assertSame([], $result['updated_language_keys']);
+ $this->assertSame(['de'], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * Installed and not installed languages at once land in their own bucket; only the installed one triggers a write.
+ */
+ public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('registerInstalledLanguage')->with('de', [], []);
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'fr'],
+ ]);
+
+ $this->assertSame(['de'], $result['updated_language_keys']);
+ $this->assertSame(['fr'], $result['not_installed_language_keys']);
+ }
+
+ /** registerInstalledLanguage() gets the available and the local languages in this order; distinguishable shapes expose a swap. */
+ public function testRegisterInstalledLanguageReceivesKnownLanguagesAndLocalLanguageKeysInTheCorrectOrder(): void
+ {
+ $setup_language = $this->createSetupLanguageMock(
+ ['en' => ['obj_id' => 1, 'status' => 'installed']], // $db_languages
+ ['fr'], // $local_language_keys
+ ['de'] // installed
+ );
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->once())
+ ->method('registerInstalledLanguage')
+ ->with(
+ 'de',
+ ['en' => ['obj_id' => 1, 'status' => 'installed']],
+ ['fr']
+ );
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ ]);
+ }
+
+ public function testInvalidLanguageFileOfAnAlreadyInstalledLanguagePreventsRefreshAndThrows(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(false);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $this->expectException(InvalidLanguageFilesException::class);
+ $this->expectExceptionCode(409);
+ $this->expectExceptionMessage('Invalid language files: de');
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ ]);
+ }
+
+ /**
+ * All invalid keys among the refreshed ones are collected into one error; the not-installed key is never validated.
+ */
+ public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de', 'xx']);
+ $setup_language->expects($this->exactly(2))
+ ->method('checkLanguageForInstallation')
+ ->willReturnMap([
+ ['de', false],
+ ['xx', false],
+ ]);
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $this->expectException(InvalidLanguageFilesException::class);
+ $this->expectExceptionCode(409);
+ $this->expectExceptionMessage('Invalid language files: de, xx');
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'xx', 'fr'], // 'fr' is not installed - untouched
+ ]);
+ }
+
+ /**
+ * If every requested language is not installed, perform() must not read the available/local languages.
+ */
+ public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirely(): void
+ {
+ $setup_language = $this->createMock(ilSetupLanguage::class);
+ $setup_language->method('getInstalledLanguages')->willReturn([]);
+ $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation');
+ $setup_language->expects($this->never())->method('getLocalLanguages');
+ $setup_language->expects($this->never())->method('checkLanguageForInstallation');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $setup_language->expects($this->never())->method('insertLanguageForInstallation');
+ $setup_language->expects($this->never())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => ['de', 'en'],
+ ]);
+
+ $this->assertSame([], $result['updated_language_keys']);
+ $this->assertSame(['de', 'en'], $result['not_installed_language_keys']);
+ }
+
+ /**
+ * UpdateLanguage must never look at whether a local file is invalid (InstallLanguage mode "install_local" does).
+ */
+ public function testNeverInspectsInvalidLocalLanguageFiles(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+ $setup_language->expects($this->never())->method('getInvalidLocalLanguageFiles');
+
+ $this->createActivity($setup_language)->perform([
+ 'language_keys' => 'de',
+ ]);
+ }
+
+ public function testDuplicateLanguageKeysAcrossStringAndArrayAreDeduplicated(): void
+ {
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->expects($this->once())
+ ->method('checkLanguageForInstallation')
+ ->with('de')
+ ->willReturn(true);
+ $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de');
+ $setup_language->expects($this->once())->method('registerInstalledLanguage');
+
+ $result = $this->createActivity($setup_language)->perform([
+ 'language_keys' => [' de, de ', 'de'],
+ ]);
+
+ $this->assertSame(['de'], $result['updated_language_keys']);
+ }
+
+ public static function invalidLanguageKeysProvider(): array
+ {
+ return [
+ 'missing language_keys key' => [[]],
+ 'empty (only whitespace/commas)' => [['language_keys' => ' , ']],
+ 'nested array value' => [['language_keys' => ['de', ['fr']]]],
+ 'three letters' => [['language_keys' => 'deu']],
+ 'one letter' => [['language_keys' => 'd']],
+ 'uppercase' => [['language_keys' => 'DE']],
+ 'contains a digit' => [['language_keys' => 'de1']],
+ 'contains a hyphen' => [['language_keys' => 'de-at']],
+ ];
+ }
+
+ #[DataProvider('invalidLanguageKeysProvider')]
+ public function testInvalidLanguageKeysAreRejected(array $parameters): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform($parameters);
+ }
+
+ /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */
+ public function testNonArrayParametersAreRejected(): void
+ {
+ $this->expectException(InvalidInputException::class);
+
+ $this->createActivity($this->createSetupLanguageStub([], [], []))->perform('not-an-array');
+ }
+
+ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField(): void
+ {
+ $text = $this->createMock(Text::class);
+ $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf();
+ $text->expects($this->once())
+ ->method('withDedicatedName')
+ ->with('language_keys')
+ ->willReturnSelf();
+
+ $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class);
+ $field->expects($this->once())->method('text')->with(
+ 'Language keys',
+ 'Comma-separated list of language keys, e.g. de, fr, it.'
+ )->willReturn($text);
+ // no mode parameter: the select field must never be built
+ $field->expects($this->never())->method('select');
+
+ $group = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class);
+ $field->expects($this->once())
+ ->method('section')
+ ->with(['language_keys' => $text], $this->anything())
+ ->willReturn($group);
+
+ $activity = $this->createActivity(
+ $this->createSetupLanguageStub([], [], [])
+ );
+
+ $this->assertSame($group, $activity->getInputDescription($field));
+ }
+
+ public function testPermissionDeniedBeforePerform(): void
+ {
+ $rbac = $this->createMock(\ilRbacSystem::class);
+ $rbac->expects($this->once())
+ ->method('checkAccessOfUser')
+ ->with(
+ 6,
+ 'write',
+ $this->anything()
+ )
+ ->willReturn(false);
+
+ $setup_language = $this->createSetupLanguageMock([], [], ['de']);
+ $setup_language->expects($this->never())->method('getInstalledLanguages');
+ $setup_language->expects($this->never())->method('flushLanguageForInstallation');
+ $result = $this->createActivity(
+ $setup_language,
+ $rbac
+ )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertTrue($result->isError());
+ }
+
+ public function testPermissionGrantedPerformsAndReturnsOkResult(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $setup_language = $this->createSetupLanguageStub([], [], ['de']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+
+ $result = $this->createActivity(
+ $setup_language,
+ $rbac
+ )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame(['de'], $result->value()['updated_language_keys']);
+ }
+
+ /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */
+ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne(): void
+ {
+ $rbac = $this->createStub(\ilRbacSystem::class);
+ $rbac->method('checkAccessOfUser')->willReturn(true);
+
+ $setup_language = $this->createSetupLanguageStub([], [], ['de', 'fr']);
+ $setup_language->method('checkLanguageForInstallation')->willReturn(true);
+
+ $result = $this->createActivity($setup_language, $rbac)
+ ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]);
+
+ $this->assertFalse($result->isError());
+ $this->assertSame(['de', 'fr'], $result->value()['updated_language_keys']);
+ }
+
+ private function createActivity(
+ ilSetupLanguage $setup_language,
+ ?\ilRbacSystem $rbac = null,
+ ?RepositoryProvider $repository = null,
+ ): UpdateLanguage {
+ return new UpdateLanguage(
+ $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)),
+ $setup_language,
+ $this->languageFolder(),
+ $repository
+ );
+ }
+
+ private function createSetupLanguageMock(
+ array $available_languages,
+ array $local_language_keys,
+ array $installed_language_keys
+ ): MockObject&ilSetupLanguage {
+ $setup_language = $this->createMock(ilSetupLanguage::class);
+ $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages);
+ $setup_language->method('getLocalLanguages')->willReturn($local_language_keys);
+ $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys);
+
+ return $setup_language;
+ }
+
+ /**
+ * Stub variant of createSetupLanguageMock() for call sites without ->expects(): a plain createMock() there
+ * triggers PHPUnit's "no expectations configured" notice.
+ *
+ * @param array $available_languages
+ * @param array $local_language_keys
+ * @param array $installed_language_keys
+ */
+ private function createSetupLanguageStub(
+ array $available_languages,
+ array $local_language_keys,
+ array $installed_language_keys
+ ): Stub&ilSetupLanguage {
+ $setup_language = $this->createStub(ilSetupLanguage::class);
+ $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages);
+ $setup_language->method('getLocalLanguages')->willReturn($local_language_keys);
+ $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys);
+
+ return $setup_language;
+ }
+}
diff --git a/components/ILIAS/Language/tests/AdminGuiReadsValuesFromMigratedFileTest.php b/components/ILIAS/Language/tests/AdminGuiReadsValuesFromMigratedFileTest.php
new file mode 100644
index 000000000000..0cfc598be465
--- /dev/null
+++ b/components/ILIAS/Language/tests/AdminGuiReadsValuesFromMigratedFileTest.php
@@ -0,0 +1,608 @@
+ the modules of every `in("module", …, negate: true)`, which the query stub applies to its rows */
+ private array $excluded_modules = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ if (!defined('ILIAS_ABSOLUTE_PATH')) {
+ define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../'));
+ }
+ // CLIENT_DATA_DIR is deliberately not defined here: seedFixtureModule() defines it, and
+ // testRefusesToWriteIntoAClientDataDirOutsideSysTempDir() needs it still undefined.
+ }
+
+ protected function tearDown(): void
+ {
+ if (isset($this->fixture_directory)) {
+ MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang');
+ MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory);
+ }
+ // never remove a foreign CLIENT_DATA_DIR
+ if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) {
+ MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Writes a .po/.mo pair for a throwaway module at the overlay location (the only one the read side uses).
+ *
+ * @param array $entries keyed by identifier;
+ * 'original' seeds LocalChangeComments::setOriginal() so a value change counts as local change.
+ * @param list $changed_identifiers identifiers whose value differs from 'original' and need a
+ * local_change comment.
+ */
+ private function seedFixtureModule(
+ string $module,
+ string $lang_key,
+ array $entries,
+ array $changed_identifiers = []
+ ): LanguageFileDirectory {
+ $created = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this);
+ $this->created_client_data_dir_root = $this->created_client_data_dir_root || $created;
+ $this->fixture_directory ??= 'tmp-admingui-values-fixtures-' . bin2hex(random_bytes(4));
+ $overlay_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key;
+ if (!is_dir($overlay_dir)) {
+ mkdir($overlay_dir, 0775, true);
+ }
+
+ $now = new DateTimeImmutable('2026-01-02T03:04:05Z', new DateTimeZone('UTC'));
+ $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog();
+ foreach ($entries as $identifier => $entry) {
+ $translation = MigratedPoFixture::entry($module, $identifier, $entry['value']);
+ if (isset($entry['original'])) {
+ LocalChangeComments::setOriginal($translation, $entry['original']);
+ }
+ if (in_array($identifier, $changed_identifiers, true)) {
+ $previous = $entry['original'] ?? '';
+ LocalChangeComments::refresh(
+ $translation,
+ $previous === $entry['value'] ? $previous . ' (old)' : $previous,
+ $entry['value'],
+ $now
+ );
+ }
+ $translations->add($translation);
+ }
+
+ $base_path = $overlay_dir . '/' . $module . '_' . $lang_key;
+ MigratedPoFixture::writePo($base_path . '.po', $translations);
+ MigratedPoFixture::writeOverlayRevision($base_path, $translations);
+
+ $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/';
+ MigratedPoFixture::writeShippedPo($relative_path, $module, $lang_key, $translations);
+
+ return new class ($module, $relative_path) implements LanguageFileDirectory {
+ public function __construct(private string $prefix, private string $path)
+ {
+ }
+
+ public function getPrefix(): string
+ {
+ return $this->prefix;
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ public function getSuffix(): string
+ {
+ return '';
+ }
+
+ public function isLocal(): bool
+ {
+ return false;
+ }
+ };
+ }
+
+ private function overlayFile(string $module, string $lang_key, string $extension): string
+ {
+ $base = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/' . $module . '_' . $lang_key;
+
+ return $extension === 'mo' ? MigratedPoFixture::overlayCurrent($base) : $base . '.' . $extension;
+ }
+
+ private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void
+ {
+ $this->setGlobalVariable(
+ LanguageFileDirectoryManager::class,
+ new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed)
+ );
+ }
+
+ /**
+ * query() returns a fresh statement per call, so repeated _getValues()/_getModules() calls each see all rows.
+ *
+ * @param list $db_rows rows lng_data would return
+ */
+ private function stubDatabase(array $db_rows): void
+ {
+ $db = $this->createStub(ilDBInterface::class);
+ $db->method('quote')->willReturnCallback(
+ static fn(mixed $value, string $type = ''): string => "'" . (string) $value . "'"
+ );
+ $db->method('in')->willReturnCallback(function (string $field, array $values, bool $negate = false): string {
+ if ($negate && $field === 'module') {
+ $this->excluded_modules = [...$this->excluded_modules, ...array_map('strval', $values)];
+ }
+
+ return "module IN ('placeholder')";
+ });
+ $db->method('like')->willReturnCallback(function (string $column, string $type, string $value = '?'): string {
+ $this->like_calls[] = [$column, $value];
+ return "value LIKE '%placeholder%'";
+ });
+ $db->method('query')->willReturnCallback(function (string $query) use ($db_rows): ilDBStatement {
+ $this->sent_queries[] = $query;
+ $statement = $this->createStub(ilDBStatement::class);
+ $calls = array_values(array_filter(
+ $db_rows,
+ fn(array $row): bool => !in_array($row['module'], $this->excluded_modules, true)
+ ));
+ $calls[] = false;
+ $statement->method('fetchRow')->willReturnOnConsecutiveCalls(...$calls);
+
+ return $statement;
+ });
+
+ $this->setGlobalVariable('ilDB', $db);
+
+ // _getValues() builds its keys from $lng->separator, whose default needs no constructor
+ $this->setGlobalVariable('lng', (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor());
+ }
+
+ public function testLoadModuleTranslationsReturnsValueAndLocalChangeFlagPerIdentifier(): void
+ {
+ $directory = $this->seedFixtureModule(
+ 'mtest',
+ 'de',
+ [
+ 'greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo'],
+ 'farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss'],
+ ],
+ ['greeting']
+ );
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+
+ $result = MigratedLanguageFileSync::loadModuleTranslations($manager, 'de', 'mtest', CLIENT_DATA_DIR);
+
+ $this->assertSame(
+ [
+ 'greeting' => [
+ 'value' => 'Hallo, geändert',
+ 'local_change' => true,
+ 'local_change_date' => '2026-01-02 03:04:05',
+ 'original' => 'Hallo',
+ ],
+ 'farewell' => [
+ 'value' => 'Tschüss',
+ 'local_change' => false,
+ 'local_change_date' => null,
+ 'original' => 'Tschüss',
+ ],
+ ],
+ $result
+ );
+ }
+
+ public function testLoadModuleTranslationsReturnsNullWhenTheModuleHasNoContributedDirectory(): void
+ {
+ $this->created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this);
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory());
+
+ $this->assertNull(
+ MigratedLanguageFileSync::loadModuleTranslations($manager, 'de', 'mtest', CLIENT_DATA_DIR)
+ );
+ }
+
+ public function testLoadModuleTranslationsReturnsTheShippedStateWithoutOverlay(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Hallo']]);
+ unlink($this->overlayFile('mtest', 'de', 'mo'));
+ unlink($this->overlayFile('mtest', 'de', 'po'));
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+
+ $this->assertSame(
+ ['greeting' => ['value' => 'Hallo', 'local_change' => false, 'local_change_date' => null, 'original' => 'Hallo']],
+ MigratedLanguageFileSync::loadModuleTranslations($manager, 'de', 'mtest', CLIENT_DATA_DIR)
+ );
+ }
+
+ public function testGetMigratedModulesListsEveryContributedModuleWithAShippedPo(): void
+ {
+ $with_mo = $this->seedFixtureModule('mone', 'de', ['greeting' => ['value' => 'Hallo']]);
+ $without_mo = $this->seedFixtureModule('mtwo', 'de', ['greeting' => ['value' => 'Hallo']]);
+ unlink($this->overlayFile('mtwo', 'de', 'mo'));
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $with_mo, $without_mo);
+
+ $this->assertSame(['mone', 'mtwo'], MigratedLanguageFileSync::getMigratedModules($manager, 'de', CLIENT_DATA_DIR));
+ $this->assertSame([], MigratedLanguageFileSync::getMigratedModules($manager, 'en', CLIENT_DATA_DIR));
+ }
+
+ public function testValueForAMigratedModuleComesFromThePoFileNotFromTheStaleDbRow(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Aktuell']]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([['module' => 'mtest', 'identifier' => 'greeting', 'value' => 'VERALTET']]);
+
+ $values = ilObjLanguageExt::_getValues('de');
+
+ $this->assertSame(['mtest#:#greeting' => 'Aktuell'], $values);
+ }
+
+ public function testValuesForANonMigratedModuleStillComeFromLngData(): void
+ {
+ $this->stubDatabase([['module' => 'rep_robj_xtst', 'identifier' => 'hello', 'value' => 'Welt']]);
+
+ $values = ilObjLanguageExt::_getValues('de');
+
+ $this->assertSame(['rep_robj_xtst#:#hello' => 'Welt'], $values);
+ }
+
+ public function testMergesAMigratedModuleWithANonMigratedOne(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Aktuell']]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([['module' => 'common', 'identifier' => 'hello', 'value' => 'Welt']]);
+
+ $values = ilObjLanguageExt::_getValues('de');
+
+ $this->assertSame(
+ ['common#:#hello' => 'Welt', 'mtest#:#greeting' => 'Aktuell'],
+ $values
+ );
+ }
+
+ public function testAppliesThePatternFilterToTheMigratedModulesValues(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', [
+ 'greeting' => ['value' => 'Hallo Welt'],
+ 'farewell' => ['value' => 'Tschüss'],
+ ]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([]);
+
+ $values = ilObjLanguageExt::_getValues('de', [], [], 'Welt');
+
+ $this->assertSame(['mtest#:#greeting' => 'Hallo Welt'], $values);
+ }
+
+ /**
+ * Search in a migrated module behaves like `UPPER(value) LIKE UPPER('%pattern%')` on lng_data: case-insensitive
+ * (also non-ASCII), "%" any sequence, "_" one character, "\" escapes, other regex characters literal.
+ *
+ * @param list $expected identifiers expected to match
+ */
+ #[DataProvider('likePatterns')]
+ public function testThePatternFilterOfAMigratedModuleFollowsSqlLikeSemantics(string $pattern, array $expected): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', array_map(static fn(string $v): array => ['value' => $v], self::likeValues()));
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([]);
+
+ $found = array_map(
+ static fn(string $key): string => substr($key, strlen('mtest#:#')),
+ array_keys(ilObjLanguageExt::_getValues('de', [], [], $pattern))
+ );
+
+ sort($found);
+ sort($expected);
+ $this->assertSame($expected, $found);
+ }
+
+ /** @return array identifier => value the LIKE patterns are matched against */
+ private static function likeValues(): array
+ {
+ return [
+ 'plain' => 'Hallo Welt',
+ 'umlaut' => 'ÄRGER über Öl',
+ 'percent' => 'Rabatt 50% heute',
+ 'fifty' => 'Rabatt 50 heute',
+ 'underscore' => 'snake_case',
+ 'snakeXcase' => 'snakeXcase',
+ 'regex' => 'a.b*c (d) [e] $f ^g |h /i',
+ 'backslash' => 'C:\\temp',
+ 'multiline' => "erste Zeile\nzweite Zeile",
+ ];
+ }
+
+ public static function likePatterns(): array
+ {
+ return [
+ 'case-insensitive ascii' => ['hallo WELT', ['plain']],
+ 'case-insensitive umlaut' => ['ärger ÜBER öl', ['umlaut']],
+ 'substring anywhere' => ['lo We', ['plain']],
+ 'percent is a wildcard' => ['Hallo%Welt', ['plain']],
+ 'percent wildcard across the value' => ['Rabatt%heute', ['fifty', 'percent']],
+ 'escaped percent is literal' => ['50\\%', ['percent']],
+ 'underscore is exactly one character' => ['snake_case', ['snakeXcase', 'underscore']],
+ 'underscore does not match zero characters' => ['Hallo_Welt', ['plain']],
+ 'escaped underscore is literal' => ['snake\\_case', ['underscore']],
+ 'two underscores need two characters' => ['Hallo__Welt', []],
+ 'two underscores match exactly two characters' => ['Hall__Welt', ['plain']],
+ 'regex meta characters are literal' => ['a.b*c (d) [e] $f ^g |h /i', ['regex']],
+ 'a dot is not a wildcard' => ['Hallo.Welt', []],
+ 'escaped backslash' => ['C:\\\\temp', ['backslash']],
+ 'wildcard spans a newline' => ['erste%zweite', ['multiline']],
+ 'consecutive percent signs match like one' => ['Hallo%%Welt', ['plain']],
+ 'only percent signs match everything' => ['%%', array_keys(self::likeValues())],
+ 'escaped percent followed by a wildcard' => ['50\\%%heute', ['percent']],
+ 'no match' => ['gibt es nicht', []],
+ ];
+ }
+
+ #[DataProvider('consecutivePercentPatterns')]
+ public function testConsecutivePercentSignsMatchLikeASinglePercentSign(string $value, string $pattern, bool $expected): void
+ {
+ $matches = new ReflectionMethod(ilObjLanguageExt::class, 'matchesLikePattern');
+
+ $this->assertSame($expected, $matches->invoke(null, $value, $pattern));
+ if (!str_contains($pattern, '\\')) {
+ $this->assertSame(
+ $expected,
+ $matches->invoke(null, $value, (string) preg_replace('/%+/', '%', $pattern)),
+ 'same result as with single percent signs'
+ );
+ }
+ }
+
+ public static function consecutivePercentPatterns(): array
+ {
+ return [
+ '%% matches an empty value' => ['', '%%', true],
+ '%% matches any value' => ['beliebig', '%%', true],
+ 'a%%%b: nothing in between' => ['ab', 'a%%%b', true],
+ 'a%%%b: something in between' => ['aXYZb', 'a%%%b', true],
+ 'a%%%b: order matters' => ['ba', 'a%%%b', false],
+ 'a%%%b: across a line break' => ["a\nb", 'a%%%b', true],
+ '%%a%% anywhere' => ['xxaxx', '%%a%%', true],
+ '%%a%% not contained' => ['xxbxx', '%%a%%', false],
+ 'escaped percent then wildcard' => ['50% Rabatt', '50\\%%Rabatt', true],
+ 'escaped percent is still required' => ['50 Rabatt', '50\\%%Rabatt', false],
+ 'underscore between percent runs' => ['ab', 'a%%_%%b', false],
+ 'underscore between percent runs, one char' => ['axb', 'a%%_%%b', true],
+ ];
+ }
+
+ /** Merged-in migrated modules are sorted like lng_data's ORDER BY under *_unicode_ci (a plain ksort() is case-sensitive). */
+ public function testValuesAreSortedCaseInsensitivelyByModuleAndIdentifier(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', [
+ 'beta' => ['value' => 'b'],
+ 'Alpha' => ['value' => 'A'],
+ 'gamma' => ['value' => 'g'],
+ 'Delta' => ['value' => 'D'],
+ ]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([
+ ['module' => 'aaa', 'identifier' => 'Zeta', 'value' => 'z'],
+ ['module' => 'zzz', 'identifier' => 'eta', 'value' => 'e'],
+ ]);
+
+ $this->assertSame(
+ ['aaa#:#Zeta', 'mtest#:#Alpha', 'mtest#:#beta', 'mtest#:#Delta', 'mtest#:#gamma', 'zzz#:#eta'],
+ array_keys(ilObjLanguageExt::_getValues('de'))
+ );
+ }
+
+ /** The SQL branch used to check the pattern for truthiness, so "0" returned every row. */
+ public function testTheSearchPatternZeroIsAppliedToLngDataAndToMigratedModules(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', [
+ 'with_zero' => ['value' => 'Version 10'],
+ 'without_zero' => ['value' => 'Version eins'],
+ ]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([]);
+
+ $values = ilObjLanguageExt::_getValues('de', [], [], '0');
+
+ $this->assertSame(['mtest#:#with_zero' => 'Version 10'], $values);
+ $this->assertSame([['value', '%0%']], $this->like_calls);
+ $this->assertStringContainsString("value LIKE '%placeholder%'", $this->sent_queries[0]);
+ }
+
+ public function testAnEmptySearchPatternDoesNotFilter(): void
+ {
+ $this->stubDatabase([['module' => 'rep_robj_xtst', 'identifier' => 'hello', 'value' => 'Welt']]);
+
+ $this->assertSame(['rep_robj_xtst#:#hello' => 'Welt'], ilObjLanguageExt::_getValues('de', [], [], ''));
+ $this->assertSame([], $this->like_calls);
+ }
+
+ public function testAnUnreadableOverlayShowsTheShippedValuesNeverLngDataWithAWarning(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Aus dem Overlay']]);
+ file_put_contents($this->overlayFile('mtest', 'de', 'po'), "msgid \"kaputt\n");
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([['module' => 'mtest', 'identifier' => 'greeting', 'value' => 'Aus lng_data']]);
+ $logger = $this->createMock(ilLogger::class);
+ $logger->expects($this->once())->method('warning')->with($this->stringContains('"mtest"'));
+ $logger_factory = $this->createStub(ilLoggerFactory::class);
+ $logger_factory->method('getComponentLogger')->willReturn($logger);
+ $this->setGlobalVariable('ilLoggerFactory', $logger_factory);
+
+ // the shipped values (the fixture ships the overlay's values), never lng_data
+ $this->assertSame(['mtest#:#greeting' => 'Aus dem Overlay'], ilObjLanguageExt::_getValues('de'));
+ }
+
+ public function testAppliesTheTopicsFilterToTheMigratedModulesValues(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', [
+ 'greeting' => ['value' => 'Hallo'],
+ 'farewell' => ['value' => 'Tschüss'],
+ ]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([]);
+
+ $values = ilObjLanguageExt::_getValues('de', [], ['farewell']);
+
+ $this->assertSame(['mtest#:#farewell' => 'Tschüss'], $values);
+ }
+
+ public function testAppliesTheChangedStateFilterToTheMigratedModulesValues(): void
+ {
+ $directory = $this->seedFixtureModule(
+ 'mtest',
+ 'de',
+ [
+ 'greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo'],
+ 'farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss'],
+ ],
+ ['greeting']
+ );
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([]);
+
+ $this->assertSame(
+ ['mtest#:#greeting' => 'Hallo, geändert'],
+ ilObjLanguageExt::_getValues('de', [], [], '', 'changed')
+ );
+ $this->assertSame(
+ ['mtest#:#farewell' => 'Tschüss'],
+ ilObjLanguageExt::_getValues('de', [], [], '', 'unchanged')
+ );
+ }
+
+ public function testWhenSpecificModulesAreRequestedAMigratedOneAmongThemStillComesFromThePoFile(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Aktuell']]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([['module' => 'common', 'identifier' => 'hello', 'value' => 'Welt']]);
+
+ $values = ilObjLanguageExt::_getValues('de', ['mtest', 'common']);
+
+ $this->assertSame(
+ ['common#:#hello' => 'Welt', 'mtest#:#greeting' => 'Aktuell'],
+ $values
+ );
+ }
+
+ /** Without the directories nothing says which `.po` to read: the rows of a shipped module are dropped (reported), others stay. */
+ public function testWithoutDirectoryManagerOnlyTheRowsOfModulesOfNoComponentAreShown(): void
+ {
+ $this->stubDatabase([
+ ['module' => 'poll', 'identifier' => 'hello', 'value' => 'Aus lng_data'],
+ ['module' => 'rep_robj_xtst', 'identifier' => 'hello', 'value' => 'Welt'],
+ ]);
+
+ $this->expectOutputRegex('/Without the language file directories the values of module "poll" are not shown/');
+ $values = ilObjLanguageExt::_getValues('de');
+
+ $this->assertSame(['rep_robj_xtst#:#hello' => 'Welt'], $values);
+ }
+
+ /** A shipped `.po` that cannot be read: the module is missing (reported), its lng_data rows are not shown instead. */
+ public function testAnUnreadableShippedPoHidesTheModuleAndNeverShowsItsLngDataRows(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Hallo']]);
+ file_put_contents(
+ constant('ILIAS_ABSOLUTE_PATH') . '/components/ILIAS/Language/tests/' . $this->fixture_directory . '/mtest_de.po',
+ "msgid \"kaputt\n"
+ );
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([
+ ['module' => 'mtest', 'identifier' => 'greeting', 'value' => 'Aus lng_data'],
+ ['module' => 'rep_robj_xtst', 'identifier' => 'hello', 'value' => 'Welt'],
+ ]);
+
+ $this->expectOutputRegex('/The language file of module "mtest" for "de" cannot be read, its values are not shown/');
+ $this->assertSame(['rep_robj_xtst#:#hello' => 'Welt'], ilObjLanguageExt::_getValues('de'));
+ }
+
+ public function testGetModulesIncludesAMigratedModuleEvenWithoutAnyLngDataRow(): void
+ {
+ $directory = $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Hallo']]);
+ $this->registerDirectoryManager($directory);
+ $this->stubDatabase([['module' => 'common']]);
+
+ $modules = ilObjLanguageExt::_getModules('de');
+
+ $this->assertSame(['common', 'mtest'], $modules);
+ }
+
+ public function testGetModulesFallsBackToDbOnlyBehaviorWhenNoDirectoryManagerIsRegisteredAtAll(): void
+ {
+ $this->stubDatabase([['module' => 'common']]);
+
+ $this->assertSame(['common'], ilObjLanguageExt::_getModules('de'));
+ }
+
+ /** Must skip instead of writing into a foreign CLIENT_DATA_DIR (e.g. /var/iliasdata); needs its own process so setUp() has not defined it. */
+ #[RunInSeparateProcess]
+ public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void
+ {
+ $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4));
+ $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/'));
+ mkdir($foreign_dir, 0775, true);
+ define('CLIENT_DATA_DIR', $foreign_dir);
+
+ try {
+ try {
+ $this->seedFixtureModule('mtest', 'de', ['greeting' => ['value' => 'Hallo']]);
+ $this->fail('seedFixtureModule() must refuse to write into a CLIENT_DATA_DIR outside sys_get_temp_dir().');
+ } catch (SkippedWithMessageException $e) {
+ $this->assertStringContainsString($foreign_dir, $e->getMessage());
+ }
+
+ $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..']));
+ } finally {
+ if (is_dir($foreign_dir)) {
+ array_map('unlink', glob($foreign_dir . '/*') ?: []);
+ rmdir($foreign_dir);
+ }
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ChildPhp.php b/components/ILIAS/Language/tests/ChildPhp.php
new file mode 100644
index 000000000000..03f3643f80a5
--- /dev/null
+++ b/components/ILIAS/Language/tests/ChildPhp.php
@@ -0,0 +1,74 @@
+ $arguments everything after the PHP binary
+ * @return list
+ */
+ public static function command(array $arguments): array
+ {
+ return [PHP_BINARY, '-d', 'xdebug.mode=off', ...$arguments];
+ }
+
+ /** @return array */
+ public static function environment(): array
+ {
+ return [...getenv(), 'XDEBUG_MODE' => 'off'];
+ }
+
+ /**
+ * @param list $arguments see command()
+ * @return array{resource, array}|null process and pipes (1 = stdout, 2 = stderr)
+ */
+ public static function open(array $arguments, ?string $working_directory = null): ?array
+ {
+ $process = proc_open(
+ self::command($arguments),
+ [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $pipes,
+ $working_directory,
+ self::environment()
+ );
+
+ return is_resource($process) ? [$process, $pipes] : null;
+ }
+
+ /**
+ * @param list $arguments see command()
+ * @return array{int, string, string}|null exit code, stdout, stderr; null if the process did not start
+ */
+ public static function run(array $arguments, ?string $working_directory = null): ?array
+ {
+ $started = self::open($arguments, $working_directory);
+ if ($started === null) {
+ return null;
+ }
+ [$process, $pipes] = $started;
+ $stdout = (string) stream_get_contents($pipes[1]);
+ $stderr = (string) stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ return [proc_close($process), $stdout, $stderr];
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/AtomicFileWriterTest.php b/components/ILIAS/Language/tests/ComponentTranslation/AtomicFileWriterTest.php
new file mode 100644
index 000000000000..75cb866dbcc6
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/AtomicFileWriterTest.php
@@ -0,0 +1,312 @@
+directory = sys_get_temp_dir() . '/ilias_atomic_' . bin2hex(random_bytes(4));
+ mkdir($this->directory, 0775);
+ }
+
+ protected function tearDown(): void
+ {
+ \MigratedPoFixture::removeDirectory($this->directory);
+ }
+
+ /**
+ * @return list
+ */
+ private function directoryListing(): array
+ {
+ return array_values(array_diff(scandir($this->directory) ?: [], ['.', '..']));
+ }
+
+ public function testWritesANewFileAndLeavesNoTemporaryFileBehind(): void
+ {
+ AtomicFileWriter::write($this->directory . '/target.po', "inhalt\n");
+
+ $this->assertSame("inhalt\n", file_get_contents($this->directory . '/target.po'));
+ $this->assertSame(['target.po'], $this->directoryListing());
+ }
+
+ public function testReplacesAnExistingFile(): void
+ {
+ file_put_contents($this->directory . '/target.po', 'alt');
+
+ AtomicFileWriter::write($this->directory . '/target.po', 'neu');
+
+ $this->assertSame('neu', file_get_contents($this->directory . '/target.po'));
+ $this->assertSame(['target.po'], $this->directoryListing());
+ }
+
+ public function testWritesEmptyContent(): void
+ {
+ AtomicFileWriter::write($this->directory . '/empty.mo', '');
+
+ $this->assertSame('', file_get_contents($this->directory . '/empty.mo'));
+ }
+
+ /**
+ * $flush_to_disk = false (used by ShippedLanguageFilesCompiledObjective for the build artifacts)
+ * skips fsync() but must still write the complete, correct content and leave no temporary file
+ * behind - only the durability-after-a-crash guarantee is dropped, nothing about the result.
+ */
+ public function testWritesCompleteContentWithFlushToDiskDisabled(): void
+ {
+ AtomicFileWriter::write($this->directory . '/target.mo', 'inhalt', null, false);
+
+ $this->assertSame('inhalt', file_get_contents($this->directory . '/target.mo'));
+ $this->assertSame(['target.mo'], $this->directoryListing());
+ }
+
+ /**
+ * $flush_to_disk = false must not disable the $confine_to_directory guard - both parameters are
+ * independent.
+ */
+ public function testFlushToDiskDisabledStillHonoursConfineToDirectory(): void
+ {
+ $outside = dirname($this->directory) . '/ilias_atomic_outside_' . bin2hex(random_bytes(4));
+ mkdir($outside, 0775);
+ try {
+ $this->expectException(RuntimeException::class);
+ AtomicFileWriter::write($outside . '/target.po', 'x', $this->directory, false);
+ } finally {
+ \MigratedPoFixture::removeDirectory($outside);
+ }
+ }
+
+ /**
+ * tempnam() creates 0600 files; the result must have the permissions a plain
+ * file_put_contents() would give (0666 & ~umask) so e.g. the web server can read what the CLI
+ * Setup wrote.
+ */
+ public function testTheWrittenFileDoesNotKeepTheRestrictiveTempnamPermissions(): void
+ {
+ $previous = umask(0022);
+ try {
+ AtomicFileWriter::write($this->directory . '/target.mo', 'x');
+ } finally {
+ umask($previous);
+ }
+
+ clearstatcache();
+ $this->assertSame('0644', sprintf('%04o', fileperms($this->directory . '/target.mo') & 0777));
+ }
+
+ /**
+ * An existing target keeps its own mode - not the "new file" mode from testTheWrittenFileDoesNotKeepTheRestrictiveTempnamPermissions()
+ * above, and not whatever tempnam() or umask would otherwise produce.
+ *
+ * Mutation: the `is_file($file) ? @fileperms($file) : ...` branch being dropped or inverted, or
+ * the `& 07777` mask being wrong (e.g. dropping the setuid/setgid/sticky bits it must preserve).
+ */
+ public function testAnExistingTargetKeepsItsOwnModeInsteadOfANewFilesMode(): void
+ {
+ file_put_contents($this->directory . '/target.po', 'alt');
+ chmod($this->directory . '/target.po', 0600);
+ $previous_umask = umask(0022); // would produce 0644 for a NEW file - must not leak in here
+ try {
+ AtomicFileWriter::write($this->directory . '/target.po', 'neu');
+ } finally {
+ umask($previous_umask);
+ }
+
+ clearstatcache();
+ $this->assertSame('0600', sprintf('%04o', fileperms($this->directory . '/target.po') & 0777));
+ }
+
+ /**
+ * grantsAtLeast() is the pure decision acceptUnchangeableMode() delegates to: whether every
+ * permission bit $intended_mode grants is also granted by $effective_mode.
+ */
+ #[DataProvider('grantsAtLeastProvider')]
+ public function testGrantsAtLeast(int $effective_mode, int $intended_mode, bool $expected): void
+ {
+ $this->assertSame($expected, AtomicFileWriter::grantsAtLeast($effective_mode, $intended_mode));
+ }
+
+ /**
+ * @return array
+ */
+ public static function grantsAtLeastProvider(): array
+ {
+ return [
+ '0600 lacks group/other read the intended 0644 needs' => [0600, 0644, false],
+ '0777 grants everything, including 0644' => [0777, 0644, true],
+ '0644 grants exactly the identical 0644 it is compared against' => [0644, 0644, true],
+ '0640 lacks group-write and other-read the intended 0664 needs' => [0640, 0664, false],
+ '0664 grants everything the less permissive 0644 needs' => [0664, 0644, true],
+ ];
+ }
+
+ /**
+ * The happy path of $confine_to_directory: a target resolving inside it is written normally - both the pre-write
+ * check and the post-rename re-check pass.
+ */
+ public function testConfineToDirectoryAllowsAWriteWhoseTargetResolvesInsideIt(): void
+ {
+ AtomicFileWriter::write($this->directory . '/target.po', 'x', $this->directory);
+
+ $this->assertSame('x', file_get_contents($this->directory . '/target.po'));
+ $this->assertSame(['target.po'], $this->directoryListing());
+ }
+
+ /**
+ * Mutation: the pre-write confinement check dropped, inverted or applied after writing - a target whose directory
+ * resolves OUTSIDE $confine_to_directory must be refused before any temporary file is created.
+ */
+ public function testConfineToDirectoryThrowsWhenTheTargetResolvesOutsideIt(): void
+ {
+ $outside = dirname($this->directory) . '/ilias_atomic_outside_' . bin2hex(random_bytes(4));
+ mkdir($outside, 0775);
+ $confine_to = $this->directory . '/allowed';
+ mkdir($confine_to, 0775);
+
+ try {
+ try {
+ AtomicFileWriter::write($outside . '/target.po', 'x', $confine_to);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('does not resolve to a path below', $e->getMessage());
+ }
+
+ $this->assertSame([], array_values(array_diff(scandir($outside) ?: [], ['.', '..'])), 'nothing must be written outside the confined directory');
+ $this->assertSame([], array_values(array_diff(scandir($confine_to) ?: [], ['.', '..'])), 'no temporary file must leak into the confined directory either');
+ } finally {
+ \MigratedPoFixture::removeDirectory($outside);
+ }
+ }
+
+ // NOT COVERED: the post-rename confinement re-check in write(). It fires only if a symlink below the confined
+ // directory is swapped while the single synchronous write() runs (the accepted TOCTOU risk of the class comment).
+ // write() is final and static with no injectable seam and no blocking primitive (flock()) to synchronize a racing
+ // process against, so it cannot be reproduced deterministically.
+
+ // NOT COVERED: acceptUnchangeableMode()'s branching on a failed chmod() (effective mode still grants enough vs.
+ // RuntimeException). There is no injectable seam, and a chmod() failure that leaves the write intact cannot be
+ // simulated: `chattr +i` is refused on both the host and the container filesystem.
+
+ public function testThrowsWhenTheTargetDirectoryDoesNotExist(): void
+ {
+ set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING);
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Could not create a temporary file/');
+ AtomicFileWriter::write($this->directory . '/missing/target.po', 'x');
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * Root-proof failure of the final rename(): the target is a directory. The temporary file must be
+ * removed again.
+ */
+ public function testThrowsWhenTheTargetCannotBeReplacedAndRemovesTheTemporaryFile(): void
+ {
+ mkdir($this->directory . '/target.po');
+
+ set_error_handler(static fn(): bool => true, E_WARNING);
+ try {
+ AtomicFileWriter::write($this->directory . '/target.po', 'x');
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('Could not replace', $e->getMessage());
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame(['target.po'], $this->directoryListing());
+ $this->assertDirectoryExists($this->directory . '/target.po');
+ }
+
+ /**
+ * Regression: tempnam() returns the resolved directory; comparing it with the unresolved one
+ * rejected every target below a symlinked directory, a "//" or a relative path.
+ */
+ public function testWritesIntoASymlinkedDirectory(): void
+ {
+ $real = $this->directory . '/real';
+ mkdir($real);
+ symlink($real, $this->directory . '/link');
+
+ AtomicFileWriter::write($this->directory . '/link/target.po', 'x');
+
+ $this->assertSame('x', file_get_contents($real . '/target.po'));
+ $this->assertSame(['target.po'], array_values(array_diff(scandir($real), ['.', '..'])));
+ }
+
+ public function testWritesWithADoubleSlashInThePath(): void
+ {
+ mkdir($this->directory . '/sub');
+
+ AtomicFileWriter::write($this->directory . '//sub//target.po', 'x');
+
+ $this->assertSame('x', file_get_contents($this->directory . '/sub/target.po'));
+ $this->assertSame(['target.po'], array_values(array_diff(scandir($this->directory . '/sub'), ['.', '..'])));
+ }
+
+ public function testWritesToARelativePath(): void
+ {
+ $cwd = getcwd();
+ chdir($this->directory);
+ try {
+ AtomicFileWriter::write('relative.po', 'x');
+ mkdir('nested');
+ AtomicFileWriter::write('nested/../nested/target.po', 'y');
+ } finally {
+ chdir($cwd);
+ }
+
+ $this->assertSame('x', file_get_contents($this->directory . '/relative.po'));
+ $this->assertSame('y', file_get_contents($this->directory . '/nested/target.po'));
+ $this->assertSame(['nested', 'relative.po'], $this->directoryListing());
+ }
+
+ /**
+ * Writing below a regular file (not a directory) must fail cleanly - also as root.
+ */
+ public function testThrowsWhenAPathComponentIsARegularFile(): void
+ {
+ file_put_contents($this->directory . '/file', 'x');
+
+ set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING);
+ try {
+ $this->expectException(RuntimeException::class);
+ AtomicFileWriter::write($this->directory . '/file/target.po', 'x');
+ } finally {
+ restore_error_handler();
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php
new file mode 100644
index 000000000000..5ff907cb0643
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php
@@ -0,0 +1,561 @@
+directory = sys_get_temp_dir() . '/ilias_translation_catalog_mo_' . bin2hex(random_bytes(4));
+ mkdir($this->directory, 0775);
+ }
+
+ protected function tearDown(): void
+ {
+ \MigratedPoFixture::removeDirectory($this->directory);
+ }
+
+ private static function entry(?string $context, string $id, string $translation): TranslationEntry
+ {
+ $entry = new TranslationEntry($context, $id);
+ $entry->translate($translation);
+
+ return $entry;
+ }
+
+ private static function sampleCatalog(): TranslationCatalog
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Language', 'de');
+ $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $catalog->add(self::entry('mod', 'zeta', 'Z'));
+ $catalog->add(self::entry('mod', 'alpha', "Mehr\nzeilig"));
+ $catalog->add(self::entry(null, 'no_context', 'ohne Kontext'));
+ $catalog->add(self::entry('mod', 'zero', '0'));
+ $fuzzy = self::entry('mod', 'fuzzy', 'Hello');
+ $fuzzy->addFlag('fuzzy');
+ $catalog->add($fuzzy);
+ $catalog->add(self::entry('mod', 'untranslated', ''));
+
+ return $catalog;
+ }
+
+ /**
+ * @return array
+ */
+ private function read(string $mo): array
+ {
+ return \MigratedPoFixture::readMoTranslations($mo);
+ }
+
+ /**
+ * The originals of a little endian `.mo`, in file order (independent of the code under test).
+ *
+ * @return list
+ */
+ private static function originals(string $mo): array
+ {
+ ['count' => $count, 'offset' => $table] = unpack('Vcount/Voffset', $mo, 8);
+ $originals = [];
+ for ($i = 0; $i < $count; $i++) {
+ ['length' => $length, 'offset' => $offset] = unpack('Vlength/Voffset', $mo, $table + $i * 8);
+ $originals[] = substr($mo, $offset, $length);
+ }
+
+ return $originals;
+ }
+
+ /**
+ * Fuzzy messages are compiled (unlike msgfmt's default - "fuzzy" marks an unreviewed legacy
+ * value, leaving it out would show "-identifier-"), untranslated ones are left out, the context
+ * is dropped and "0" is served as "0".
+ */
+ public function testReadMoTranslationsServesEveryTranslatedMessageByIdentifier(): void
+ {
+ $this->assertSame(
+ [
+ 'alpha' => "Mehr\nzeilig",
+ 'fuzzy' => 'Hello',
+ 'zero' => '0',
+ 'zeta' => 'Z',
+ 'no_context' => 'ohne Kontext',
+ ],
+ $this->read(self::sampleCatalog()->toMoString())
+ );
+ }
+
+ /**
+ * msgfmt-compatible layout: the header message ("" => headers) comes first, the originals are
+ * sorted byte-wise (C gettext looks them up by binary search), untranslated messages are absent.
+ */
+ public function testTheOriginalsAreTheHeaderAndTheTranslatedMessagesSortedByteWise(): void
+ {
+ $catalog = new TranslationCatalog();
+ foreach (['b', 'B', 'a', '10', '9'] as $id) {
+ $catalog->add(self::entry(null, $id, 'x'));
+ }
+ $catalog->add(self::entry('mod', 'a', 'x'));
+ $catalog->add(self::entry('mod', 'empty', ''));
+
+ $this->assertSame(['', '10', '9', 'B', 'a', 'b', "mod\x04a"], self::originals($catalog->toMoString()));
+ }
+
+ /**
+ * The "0" workaround stores "0" plus its NUL; C gettext and readMoTranslations() end the string
+ * there. The next message must not be affected.
+ */
+ public function testTheValueZeroDoesNotAffectTheFollowingMessage(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(self::entry('mod', 'a', '0'));
+ $catalog->add(self::entry('mod', 'b', 'bee'));
+
+ $this->assertSame(['a' => '0', 'b' => 'bee'], $this->read($catalog->toMoString()));
+ }
+
+ public function testNumericIdentifiersAreServed(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(self::entry('mod', '0', 'null'));
+ $catalog->add(self::entry('mod', '10', 'zehn'));
+
+ $translations = $this->read($catalog->toMoString());
+
+ $this->assertSame('null', $translations['0'] ?? null);
+ $this->assertSame('zehn', $translations['10'] ?? null);
+ $this->assertCount(2, $translations);
+ }
+
+ /**
+ * A plural message is served with its default form (msgstr[1] with at least two forms, see PluralForms); every form
+ * is available through readMoMessages().
+ */
+ public function testReadMoTranslationsServesTheDefaultFormOfAPluralMessage(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\n"
+ . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\nmsgstr[0] \"Eintrag\"\nmsgstr[1] \"Einträge\"\n"
+ );
+
+ $mo = $catalog->toMoString();
+
+ $this->assertSame(['item' => 'Einträge'], $this->read($mo));
+ $this->assertSame(['item' => ['Eintrag', 'Einträge']], \MigratedPoFixture::readMoPluralForms($mo));
+ $this->assertSame(['', "mod\x04item\x00items"], self::originals($mo));
+ $this->assertStringContainsString("Eintrag\x00Einträge\x00", $mo);
+ }
+
+ /**
+ * A language with a single plural form (nplurals=1, e.g. Japanese) writes only msgstr[0] to the
+ * `.mo` (prepareMoPluralForms() pads/cuts to "as many forms as the header declares") - with no
+ * form left after it, the message is compiled as an ordinary singular one (see toMoString()'s
+ * docblock), so it is served as a plain translation, not through getPluralTranslations().
+ */
+ public function testAPluralMessageOfALanguageWithASingleFormIsCompiledAsSingular(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=1; plural=0;\\n\"\n\n"
+ . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\nmsgstr[0] \"Eintrag\"\nmsgstr[1] \"Einträge\"\n"
+ );
+
+ $mo = $catalog->toMoString();
+
+ $this->assertSame(['item' => 'Eintrag'], $this->read($mo));
+ $this->assertSame([], \MigratedPoFixture::readMoPluralForms($mo));
+ }
+
+ /**
+ * An empty form between two non-empty ones (e.g. Russian's msgstr[1] left blank while msgstr[0]
+ * and msgstr[2] are set) must roundtrip as an empty string at that index, not shift the later
+ * forms or get treated as "message ends here".
+ */
+ public function testAnEmptyIntermediateFormOfAPluralMessageRoundTrips(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n"
+ . "\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4"
+ . " && (n%100<10 || n%100>=20) ? 1 : 2);\\n\"\n\n"
+ . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\n"
+ . "msgstr[0] \"eins\"\nmsgstr[1] \"\"\nmsgstr[2] \"viele\"\n"
+ );
+
+ $mo = $catalog->toMoString();
+
+ $this->assertSame(
+ ['item' => ['eins', '', 'viele']],
+ \MigratedPoFixture::readMoPluralForms($mo)
+ );
+ // the default form is the last one (index 2, count 3) - it is not empty here, so no fallback
+ // search is even needed (see PluralFormsTest for that)
+ $this->assertSame(['item' => 'viele'], $this->read($mo));
+ }
+
+ /**
+ * The LAST form (not msgstr[1]) is the default - and when it is itself empty, the search falls
+ * back downwards to the closest non-empty one before it (here: msgstr[1], skipping the always-
+ * empty msgstr[2] slot of this fixture's Czech-shaped rule).
+ */
+ public function testReadMoTranslationsFallsBackDownwardsWhenTheLastFormIsEmpty(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n"
+ . "\"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\\n\"\n\n"
+ . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\n"
+ . "msgstr[0] \"jeden\"\nmsgstr[1] \"dva\"\nmsgstr[2] \"\"\n"
+ );
+
+ $mo = $catalog->toMoString();
+
+ $this->assertSame(['item' => 'dva'], $this->read($mo));
+ }
+
+ /**
+ * An invalid "Plural-Forms" header (here: nplurals=999, far beyond PluralForms::MAX_COUNT of 10 -
+ * the gettext/gettext library itself does not enforce that bound) must not make toMoString()
+ * compile 999 forms: prepareMoPluralForms() falls back to the same Germanic rule PluralForms
+ * itself falls back to, both for how many forms are actually written (surplus ones dropped) and
+ * for the "Plural-Forms" header the `.mo` ends up with.
+ */
+ public function testAnInvalidNpluralsHeaderIsCappedToTheGermanicFallbackWhenCompiling(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=999; plural=n%999;\\n\"\n\n"
+ . "msgid \"item\"\nmsgid_plural \"items\"\nmsgstr[0] \"A\"\nmsgstr[1] \"B\"\nmsgstr[2] \"C\"\n"
+ );
+
+ $mo = $catalog->toMoString();
+ $this->assertSame('nplurals=2; plural=(n != 1);', \MigratedPoFixture::moPluralFormsHeader($mo));
+ $this->assertSame(['item' => ['A', 'B']], \MigratedPoFixture::readMoPluralForms($mo), 'the surplus form C is dropped');
+ $this->assertSame(['item' => 'B'], $this->read($mo));
+ }
+
+ /**
+ * In a plural message the NUL of the "0" workaround would become an additional, empty plural
+ * form - compiling it throws instead (ILIAS itself uses no plurals).
+ */
+ public function testASingularTranslationZeroOfAPluralMessageCannotBeCompiled(): void
+ {
+ $catalog = TranslationCatalog::fromPoString("msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"0\"\nmsgstr[1] \"1\"\n");
+
+ $this->expectException(RuntimeException::class);
+ $catalog->toMoString();
+ }
+
+ public function testAnEmptyCatalogCompilesToAValidMoWithoutMessages(): void
+ {
+ $mo = (new TranslationCatalog())->toMoString();
+
+ $this->assertSame([''], self::originals($mo), 'only the (empty) header');
+ $this->assertSame([], $this->read($mo));
+ }
+
+ /**
+ * MigratedLanguageFileSync::sync() decides by byte comparison whether the `.mo` is current - so
+ * the same content must always compile to the same bytes, independent of the insertion order
+ * and of how often it was compiled.
+ */
+ public function testToMoStringIsDeterministic(): void
+ {
+ $build = static function (array $ids): TranslationCatalog {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Language', 'de');
+ foreach ($ids as $id) {
+ $catalog->add(self::entry('mod', $id, $id === 'zero' ? '0' : 'Wert ' . $id));
+ }
+ return $catalog;
+ };
+ $catalog = $build(['b', 'zero', 'a', '10', '9', 'B']);
+
+ $mo = $catalog->toMoString();
+
+ $this->assertSame($mo, $catalog->toMoString());
+ $this->assertSame($mo, $build(['b', 'zero', 'a', '10', '9', 'B'])->toMoString());
+ $this->assertSame($mo, $build(['9', 'B', 'zero', '10', 'a', 'b'])->toMoString());
+ $this->assertSame($mo, TranslationCatalog::fromPoString($catalog->toPoString())->toMoString());
+ $this->assertNotSame($mo, $build(['b', 'zero', 'a', '10', '9'])->toMoString());
+ }
+
+ /**
+ * The `.mo` content depends on everything ilLanguage serves - a changed value must produce
+ * different bytes, or the self-healing byte comparison of sync() would keep a stale `.mo`.
+ */
+ public function testAChangedValueChangesTheMo(): void
+ {
+ $catalog = self::sampleCatalog();
+ $mo = $catalog->toMoString();
+
+ $catalog->find('mod', 'zeta')?->translate('Y');
+
+ $this->assertNotSame($mo, $catalog->toMoString());
+ }
+
+ /**
+ * The library's PHP warnings/notices say "this file is broken" and become a RuntimeException.
+ * The adapter's structural pre-checks keep MoLoader from ever raising one for a real file, so
+ * this is exercised directly on the private helper.
+ */
+ #[DataProvider('libraryWarnings')]
+ public function testLibraryWarningsAndNoticesBecomeRuntimeExceptions(int $level): void
+ {
+ $helper = new ReflectionMethod(TranslationCatalog::class, 'withLibraryErrorsAsExceptions');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('library message');
+ $helper->invoke(null, static function () use ($level): Translations {
+ trigger_error('library message', $level);
+ return Translations::create();
+ });
+ }
+
+ public static function libraryWarnings(): array
+ {
+ return [
+ 'warning' => [E_USER_WARNING],
+ 'notice' => [E_USER_NOTICE],
+ ];
+ }
+
+ /**
+ * A deprecation only says something about the library on this PHP version, not about the file:
+ * the load succeeds and the deprecation goes to the error handler registered before (ILIAS' own
+ * or, in a test run, PHPUnit's) - which is active again afterward.
+ */
+ public function testLibraryDeprecationsArePassedOnToThePreviousHandlerAndDoNotFailTheLoad(): void
+ {
+ $helper = new ReflectionMethod(TranslationCatalog::class, 'withLibraryErrorsAsExceptions');
+ $received = [];
+ $previous_handler = static function (int $severity, string $message, string $file, int $line) use (&$received): bool {
+ $received[] = [$severity, $message, $file, $line];
+ return true;
+ };
+ set_error_handler($previous_handler);
+ try {
+ $result = $helper->invoke(null, static function (): Translations {
+ trigger_error('library deprecation', E_USER_DEPRECATED);
+ return Translations::create()->setDomain('loaded');
+ });
+ $trigger_line = __LINE__ - 3;
+
+ $current = set_error_handler(null);
+ restore_error_handler();
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertInstanceOf(Translations::class, $result);
+ $this->assertSame('loaded', $result->getDomain());
+ $this->assertSame([[E_USER_DEPRECATED, 'library deprecation', __FILE__, $trigger_line]], $received);
+ $this->assertSame($previous_handler, $current, 'the previous handler is restored');
+ }
+
+ /**
+ * A previous handler that declines (returns false) leaves the deprecation to PHP's standard
+ * handling (here: the error log) - still without failing the load.
+ */
+ public function testALibraryDeprecationDeclinedByThePreviousHandlerGoesToPhpsStandardHandling(): void
+ {
+ $helper = new ReflectionMethod(TranslationCatalog::class, 'withLibraryErrorsAsExceptions');
+ $log = $this->directory . '/error.log';
+ $ini = ['error_log' => $log, 'log_errors' => '1', 'display_errors' => '0'];
+ $previous = [];
+ foreach ($ini as $name => $value) {
+ $previous[$name] = ini_set($name, $value);
+ }
+ $previous_level = error_reporting(E_ALL);
+ $calls = 0;
+ set_error_handler(static function () use (&$calls): bool {
+ $calls++;
+ return false;
+ });
+ try {
+ $result = $helper->invoke(null, static function (): Translations {
+ trigger_error('declined library deprecation', E_USER_DEPRECATED);
+ return Translations::create()->setDomain('loaded');
+ });
+ } finally {
+ restore_error_handler();
+ error_reporting($previous_level);
+ foreach ($previous as $name => $value) {
+ ini_set($name, (string) $value);
+ }
+ }
+
+ $this->assertSame('loaded', $result->getDomain());
+ $this->assertSame(1, $calls);
+ $this->assertStringContainsString('declined library deprecation', (string) @file_get_contents($log));
+ }
+
+ /**
+ * Passing deprecations on must not pass on warnings: those still become a RuntimeException and
+ * never reach the previous handler.
+ */
+ public function testLibraryWarningsDoNotReachThePreviousHandler(): void
+ {
+ $helper = new ReflectionMethod(TranslationCatalog::class, 'withLibraryErrorsAsExceptions');
+ $calls = 0;
+ set_error_handler(static function () use (&$calls): bool {
+ $calls++;
+ return true;
+ });
+ try {
+ $helper->invoke(null, static function (): Translations {
+ trigger_error('library warning', E_USER_WARNING);
+ return Translations::create();
+ });
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertSame('library warning', $e->getMessage());
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame(0, $calls);
+ }
+
+ public function testThrowablesOfTheLibraryBecomeRuntimeExceptions(): void
+ {
+ $helper = new ReflectionMethod(TranslationCatalog::class, 'withLibraryErrorsAsExceptions');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('type error');
+ $helper->invoke(null, static function (): Translations {
+ throw new \TypeError('type error');
+ });
+ }
+
+ /**
+ * fromMoString() reads back what toMoString() wrote: values incl. "0" and multi-line, context, plural forms, headers.
+ */
+ public function testFromMoStringReadsBackWhatToMoStringWrote(): void
+ {
+ $catalog = self::sampleCatalog();
+ $plural = new TranslationEntry('mod', 'item');
+ $plural->setPlural('items', ['Eintrag', 'Einträge']);
+ $catalog->add($plural);
+
+ $read = TranslationCatalog::fromMoString($catalog->toMoString());
+
+ $this->assertSame('de', $read->getHeader('Language'));
+ $this->assertSame('nplurals=2; plural=(n != 1);', $read->getHeader('Plural-Forms'));
+ $this->assertSame('0', $read->find('mod', 'zero')?->getTranslation(), 'the value "0" is not "0" plus its NUL');
+ $this->assertSame("Mehr\nzeilig", $read->find('mod', 'alpha')?->getTranslation());
+ $this->assertSame('Z', $read->find('mod', 'zeta')?->getTranslation());
+ $this->assertSame('Hello', $read->find('mod', 'fuzzy')?->getTranslation(), 'fuzzy messages are compiled');
+ $this->assertSame('ohne Kontext', $read->find(null, 'no_context')?->getTranslation());
+ $this->assertNull($read->find('mod', 'untranslated'), 'left out of the .mo');
+ $item = $read->find('mod', 'item');
+ $this->assertNotNull($item);
+ $this->assertTrue($item->isPlural());
+ $this->assertSame('items', $item->getPluralId());
+ $this->assertSame(['Eintrag', 'Einträge'], $item->getPluralTranslations());
+ $this->assertCount(6, $read->getEntries());
+ }
+
+ /** For singular messages (also "0") and plurals without empty or "0" forms the read catalog compiles to the same values. */
+ public function testAReadMoWithoutEmptyOrZeroPluralFormsCompilesToTheSameValuesAgain(): void
+ {
+ $catalog = self::sampleCatalog();
+ $plural = new TranslationEntry('mod', 'item');
+ $plural->setPlural('items', ['Eintrag', 'Einträge']);
+ $catalog->add($plural);
+ $mo = $catalog->toMoString();
+
+ $this->assertSame(
+ $this->read($mo),
+ $this->read(TranslationCatalog::fromMoString($mo)->toMoString())
+ );
+ $this->assertSame(
+ \MigratedPoFixture::readMoPluralForms($mo),
+ \MigratedPoFixture::readMoPluralForms(TranslationCatalog::fromMoString($mo)->toMoString())
+ );
+ }
+
+ /**
+ * @return array, list}> forms written, forms read back
+ */
+ public static function pluralFormsTheLibraryDrops(): array
+ {
+ return [
+ 'empty intermediate form' => [['eins', '', 'viele'], ['eins', 'viele']],
+ 'form "0"' => [['eins', 'zwei', '0'], ['eins', 'zwei']],
+ ];
+ }
+
+ /**
+ * Documents why fromMoString() is for reading only: the library drops empty plural forms and forms "0".
+ *
+ * @param list $written
+ * @param list $read
+ */
+ #[DataProvider('pluralFormsTheLibraryDrops')]
+ public function testFromMoStringLosesEmptyAndZeroPluralForms(array $written, array $read): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Plural-Forms', 'nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);');
+ $entry = new TranslationEntry('mod', 'item');
+ $entry->setPlural('items', $written);
+ $catalog->add($entry);
+ $mo = $catalog->toMoString();
+ $this->assertSame(['item' => $written], \MigratedPoFixture::readMoPluralForms($mo), 'precondition: the .mo holds every form');
+
+ $item = TranslationCatalog::fromMoString($mo)->find('mod', 'item');
+
+ $this->assertSame($read, $item?->getPluralTranslations());
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidMoData(): array
+ {
+ $mo = self::sampleCatalog()->toMoString();
+
+ return [
+ 'empty' => [''],
+ 'no .mo' => ['this is no gettext catalog'],
+ 'cut in the header' => [substr($mo, 0, 20)],
+ 'cut in the tables' => [substr($mo, 0, 30)],
+ ];
+ }
+
+ #[DataProvider('invalidMoData')]
+ public function testFromMoStringRejectsInvalidOrTruncatedData(string $content): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ TranslationCatalog::fromMoString($content);
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php
new file mode 100644
index 000000000000..101796d4baea
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php
@@ -0,0 +1,863 @@
+directory !== null) {
+ \MigratedPoFixture::removeDirectory($this->directory);
+ }
+ }
+
+ private function temporaryDirectory(): string
+ {
+ if ($this->directory === null) {
+ $this->directory = sys_get_temp_dir() . '/ilias_translation_catalog_' . bin2hex(random_bytes(4));
+ mkdir($this->directory, 0775);
+ }
+
+ return $this->directory;
+ }
+
+ private static function roundTrip(TranslationCatalog $catalog): TranslationCatalog
+ {
+ return TranslationCatalog::fromPoString($catalog->toPoString());
+ }
+
+ private static function singleEntryCatalog(TranslationEntry $entry): TranslationCatalog
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add($entry);
+
+ return $catalog;
+ }
+
+ private static function entry(?string $context, string $id, string $translation): TranslationEntry
+ {
+ $entry = new TranslationEntry($context, $id);
+ $entry->translate($translation);
+
+ return $entry;
+ }
+
+ #[DataProvider('values')]
+ public function testValuesRoundTripExactly(string $value): void
+ {
+ $read = self::roundTrip(self::singleEntryCatalog(self::entry('mod', 'id', $value)))->find('mod', 'id');
+
+ $this->assertNotNull($read);
+ $this->assertSame($value, $read->getTranslation());
+ }
+
+ public static function values(): array
+ {
+ return [
+ 'empty' => [''],
+ 'zero' => ['0'],
+ 'zero with a line break' => ["0\n"],
+ 'double zero' => ['00'],
+ 'plain' => ['Hallo'],
+ 'double quote' => ['Klick "hier"'],
+ 'backslash' => ['C:\\temp\\'],
+ 'backslash before quote' => ['a\\"b'],
+ 'only a backslash' => ['\\'],
+ 'tab and carriage return' => ["a\tb\rc"],
+ 'multiline' => ["Zeile 1\nZeile 2\nZeile 3"],
+ 'trailing newline' => ["Zeile\n"],
+ 'only newlines' => ["\n\n"],
+ 'leading and trailing spaces' => [' Hallo '],
+ 'unicode' => ['Ünïcödé – 日本語 🙂'],
+ 'placeholder and html' => ['%s'],
+ 'literal escape sequence text' => ['\\n is not a newline'],
+ 'hash at start' => ['# not a comment'],
+ 'msgid-like text' => ["msgid \"x\"\nmsgstr \"y\""],
+ ];
+ }
+
+ /**
+ * PoGenerator treats a falsy translation as absent - without the adapter's workaround "0" (it
+ * occurs in the legacy ilias_fr.lang/ilias_pl.lang) would be written as an empty msgstr.
+ */
+ public function testTheValueZeroIsWrittenAsExactlyZero(): void
+ {
+ $po = self::singleEntryCatalog(self::entry('mod', 'zero', '0'))->toPoString();
+
+ $this->assertStringContainsString("msgctxt \"mod\"\nmsgid \"zero\"\nmsgstr \"0\"\n", $po);
+ $this->assertStringNotContainsString("\0", $po);
+ }
+
+ /**
+ * The workaround works on a copy: generating must not change the catalog it was called on (a
+ * leaked "0\0" would, e.g., make the next toPoString()/toMoString() or a value comparison differ).
+ */
+ public function testGeneratingDoesNotChangeTheCatalog(): void
+ {
+ $entry = self::entry('mod', 'zero', '0');
+ $catalog = self::singleEntryCatalog($entry);
+
+ $first_po = $catalog->toPoString();
+ $first_mo = $catalog->toMoString();
+
+ $this->assertSame('0', $entry->getTranslation());
+ $this->assertSame('0', $catalog->find('mod', 'zero')?->getTranslation());
+ $this->assertSame($first_po, $catalog->toPoString());
+ $this->assertSame($first_mo, $catalog->toMoString());
+ }
+
+ public function testIdentifierAndContextRoundTripIncludingSpecialCharacters(): void
+ {
+ $catalog = self::singleEntryCatalog(self::entry("ctx \"q\"", "id\\with\nnewline", 'v'));
+
+ $this->assertSame('v', self::roundTrip($catalog)->find("ctx \"q\"", "id\\with\nnewline")?->getTranslation());
+ }
+
+ /**
+ * Boundary of the "falsy" workaround: an identifier "0" is not special.
+ */
+ public function testAnIdentifierZeroRoundTrips(): void
+ {
+ $catalog = self::singleEntryCatalog(self::entry('mod', '0', 'null'));
+
+ $this->assertSame('null', self::roundTrip($catalog)->find('mod', '0')?->getTranslation());
+ }
+
+ /**
+ * The library cannot represent a context or msgid_plural of "" or "0" (PoGenerator/MoGenerator
+ * skip falsy ones, which would silently merge the message into the context-less one). Generating
+ * such a catalog throws instead of writing a different catalog.
+ */
+ #[DataProvider('unrepresentableMessages')]
+ public function testAnUnrepresentableContextOrPluralIdThrowsWhenGenerating(string $po, bool $mo): void
+ {
+ $catalog = TranslationCatalog::fromPoString($po);
+
+ $this->expectException(RuntimeException::class);
+ $mo ? $catalog->toMoString() : $catalog->toPoString();
+ }
+
+ public static function unrepresentableMessages(): array
+ {
+ $cases = [
+ 'empty context' => "msgctxt \"\"\nmsgid \"a\"\nmsgstr \"b\"\n",
+ 'context zero' => "msgctxt \"0\"\nmsgid \"a\"\nmsgstr \"b\"\n",
+ 'empty plural id' => "msgid \"a\"\nmsgid_plural \"\"\nmsgstr[0] \"b\"\nmsgstr[1] \"c\"\n",
+ 'plural id zero' => "msgid \"a\"\nmsgid_plural \"0\"\nmsgstr[0] \"b\"\nmsgstr[1] \"c\"\n",
+ ];
+ $data = [];
+ foreach ($cases as $name => $po) {
+ $data[$name . ', .po'] = [$po, false];
+ $data[$name . ', .mo'] = [$po, true];
+ }
+
+ return $data;
+ }
+
+ public function testAnEntryCreatedWithAContextZeroThrowsWhenGenerating(): void
+ {
+ $catalog = self::singleEntryCatalog(self::entry('0', 'a', 'b'));
+
+ $this->expectException(RuntimeException::class);
+ $catalog->toPoString();
+ }
+
+ /**
+ * "original: ..." comments are compared with the value byte for byte - surrounding whitespace
+ * must survive; the loader must strip exactly the one space after the comment marker.
+ */
+ public function testCommentsAndFlagsRoundTripWithoutTrimming(): void
+ {
+ $entry = self::entry('mod', 'id', 'v');
+ $entry->addTranslatorComment('original: Hallo ');
+ $entry->addTranslatorComment('');
+ $entry->addTranslatorComment(' leading space');
+ $entry->addTranslatorComment('trailing space ');
+ $entry->addTranslatorComment('.looks like an extracted marker');
+ $entry->addExtractedComment(' extracted ');
+ $entry->addFlag('php-format');
+ $entry->addFlag('fuzzy');
+
+ $read = self::roundTrip(self::singleEntryCatalog($entry))->find('mod', 'id');
+
+ $this->assertNotNull($read);
+ $this->assertSame(
+ ['original: Hallo ', '', ' leading space', 'trailing space ', '.looks like an extracted marker'],
+ $read->getTranslatorComments()
+ );
+ $this->assertSame([' extracted '], $read->getExtractedComments());
+ $this->assertSame(['fuzzy', 'php-format'], $read->getFlags());
+ $this->assertTrue($read->hasFlag('fuzzy'));
+ }
+
+ /**
+ * Layout of comments and flags as the library writes them (characterization): an empty comment
+ * is "# " (the old writer wrote "#"), flags are sorted and joined by "," without a space.
+ */
+ public function testTheCommentAndFlagLayout(): void
+ {
+ $entry = self::entry('mod', 'id', 'v');
+ $entry->addTranslatorComment('');
+ $entry->addTranslatorComment(' note ');
+ $entry->addExtractedComment('ext');
+ $entry->addFlag('php-format');
+ $entry->addFlag('fuzzy');
+
+ $this->assertStringContainsString(
+ "# \n# note \n#. ext\n#, fuzzy,php-format\nmsgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"v\"\n",
+ self::singleEntryCatalog($entry)->toPoString()
+ );
+ }
+
+ public function testCommentsWrittenWithoutASpaceAfterTheMarkerAreReadAsIs(): void
+ {
+ $entry = TranslationCatalog::fromPoString("#note\n#.ext\n#\nmsgid \"id\"\nmsgstr \"v\"\n")->find(null, 'id');
+
+ $this->assertNotNull($entry);
+ $this->assertSame(['note', ''], $entry->getTranslatorComments());
+ $this->assertSame(['ext'], $entry->getExtractedComments());
+ }
+
+ /**
+ * A comment is a single `.po` line: a line break inside it would end the comment and corrupt
+ * the file, so it is replaced by a space when added.
+ */
+ public function testALineBreakInACommentIsReplacedByASpace(): void
+ {
+ $entry = self::entry('mod', 'id', 'v');
+ $entry->addTranslatorComment("eins\nzwei\r\ndrei\rvier");
+ $entry->addExtractedComment("a\nb");
+
+ $read = self::roundTrip(self::singleEntryCatalog($entry))->find('mod', 'id');
+
+ $this->assertSame(['eins zwei drei vier'], $entry->getTranslatorComments());
+ $this->assertNotNull($read);
+ $this->assertSame(['eins zwei drei vier'], $read->getTranslatorComments());
+ $this->assertSame(['a b'], $read->getExtractedComments());
+ }
+
+ /**
+ * ILIAS itself has no plural API any more, but a loaded plural message is carried along and
+ * written back unchanged - also after its singular translation or comments were changed.
+ */
+ public function testALoadedPluralMessageRoundTripsByteIdentically(): void
+ {
+ $po = "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 ? 1 : 2);\\n\"\n\n"
+ . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\nmsgstr[0] \"%d Eintrag\"\nmsgstr[1] \"%d Einträge\"\n"
+ . "msgstr[2] \"%d Einträgen\"\n";
+
+ $catalog = TranslationCatalog::fromPoString($po);
+
+ $this->assertSame($po, $catalog->toPoString());
+ $entry = $catalog->find('mod', 'item');
+ $this->assertNotNull($entry);
+ $this->assertSame('%d Eintrag', $entry->getTranslation());
+
+ $entry->translate('%d Stück');
+ $entry->addTranslatorComment('note');
+
+ $this->assertSame(
+ str_replace("msgctxt \"mod\"\nmsgid \"item\"", "# note\nmsgctxt \"mod\"\nmsgid \"item\"", str_replace('"%d Eintrag"', '"%d Stück"', $po)),
+ $catalog->toPoString()
+ );
+ }
+
+ /**
+ * Documented deviation from the GNU tools: the number of msgstr[n] written follows the
+ * Plural-Forms header - missing forms are written empty, surplus ones are dropped.
+ */
+ #[DataProvider('pluralFormCounts')]
+ public function testTheNumberOfWrittenPluralFormsFollowsThePluralFormsHeader(string $header, string $forms, string $expected): void
+ {
+ $po = "msgid \"\"\nmsgstr \"\"\n" . $header . "\n"
+ . "msgid \"item\"\nmsgid_plural \"items\"\n" . $forms;
+
+ $this->assertSame(
+ "msgid \"\"\nmsgstr \"\"\n" . $header . "\nmsgid \"item\"\nmsgid_plural \"items\"\n" . $expected,
+ TranslationCatalog::fromPoString($po)->toPoString()
+ );
+ }
+
+ public static function pluralFormCounts(): array
+ {
+ $two = "msgstr[0] \"a\"\nmsgstr[1] \"b\"\n";
+ $three = $two . "msgstr[2] \"c\"\n";
+
+ return [
+ 'as many as declared' => ["\"Plural-Forms: nplurals=3; plural=(n != 1);\\n\"\n", $three, $three],
+ 'fewer than declared' => ["\"Plural-Forms: nplurals=3; plural=(n != 1);\\n\"\n", $two, $two . "msgstr[2] \"\"\n"],
+ 'more than declared' => ["\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n", $three, $two],
+ 'no header' => ['', $two, $two],
+ ];
+ }
+
+ public function testASingularTranslationZeroOfAPluralMessageIsWrittenToThePo(): void
+ {
+ $po = "msgid \"\"\nmsgstr \"\"\n\nmsgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"0\"\nmsgstr[1] \"0\"\n";
+
+ $this->assertSame($po, TranslationCatalog::fromPoString($po)->toPoString());
+ }
+
+ public function testHeadersAreSortedByNameRegardlessOfTheOrderTheyWereSetIn(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('X-Domain', 'tos');
+ $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ $catalog->setHeader('Language', 'de');
+
+ $expected = [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ 'Language' => 'de',
+ 'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
+ 'X-Domain' => 'tos',
+ ];
+ $this->assertSame($expected, $catalog->getHeaders());
+ $this->assertSame(
+ "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Language: de\\n\"\n"
+ . "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"X-Domain: tos\\n\"\n",
+ $catalog->toPoString()
+ );
+ $read = self::roundTrip($catalog);
+ $this->assertSame($expected, $read->getHeaders());
+ $this->assertSame([], $read->getEntries(), 'the header is not an entry');
+ }
+
+ public function testSetHeaderReplacesAValueTrimsItAndGetHeaderReturnsNullForAMissingOne(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Language', 'fr');
+ $catalog->setHeader('Language', " \t de ");
+
+ $this->assertSame('de', $catalog->getHeader('Language'));
+ $this->assertSame(['Language' => 'de'], $catalog->getHeaders());
+ $this->assertNull($catalog->getHeader('X-Domain'));
+ }
+
+ /**
+ * The `.po` header block is split at line breaks when read - a value containing one could not
+ * be read back unchanged, so it is refused (and the header keeps its previous value).
+ */
+ #[DataProvider('headerValuesWithALineBreak')]
+ public function testSetHeaderRefusesAValueWithALineBreak(string $value): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('X-Note', 'bisher');
+
+ try {
+ $catalog->setHeader('X-Note', $value);
+ $this->fail('Expected an InvalidArgumentException');
+ } catch (InvalidArgumentException $e) {
+ $this->assertStringContainsString('X-Note', $e->getMessage());
+ }
+
+ $this->assertSame(['X-Note' => 'bisher'], $catalog->getHeaders());
+ }
+
+ public static function headerValuesWithALineBreak(): array
+ {
+ return [
+ 'LF inside' => ["a\nb"],
+ 'CR inside' => ["a\rb"],
+ 'CRLF inside' => ["a\r\nb"],
+ 'trailing LF' => ["a\n"],
+ 'leading CR' => ["\ra"],
+ 'only CRLF' => ["\r\n"],
+ ];
+ }
+
+ /**
+ * PoGenerator writes header values verbatim - a quote or backslash would end the string early
+ * and corrupt the whole file.
+ */
+ public function testHeaderValuesWithQuotesAndBackslashesAreEscaped(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('X-Note', 'a "quoted" C:\\pfad\\');
+ $catalog->setHeader('X-Zero', '0');
+
+ $po = $catalog->toPoString();
+
+ $this->assertStringContainsString("\"X-Note: a \\\"quoted\\\" C:\\\\pfad\\\\\\n\"\n", $po);
+ $this->assertSame(
+ ['X-Note' => 'a "quoted" C:\\pfad\\', 'X-Zero' => '0'],
+ TranslationCatalog::fromPoString($po)->getHeaders()
+ );
+ }
+
+ /**
+ * An escaped CR that stays inside a header value after loading could be read but never written
+ * back (setHeader() refuses it) - such a file counts as broken.
+ */
+ #[DataProvider('headersWithALineBreakAfterLoading')]
+ public function testAHeaderValueKeepingALineBreakAfterLoadingIsRejected(string $po): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('The value of header "X-A" contains a line break.');
+ TranslationCatalog::fromPoString($po);
+ }
+
+ public static function headersWithALineBreakAfterLoading(): array
+ {
+ return [
+ 'CR inside' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n"],
+ 'CR inside, last header without a trailing \\n' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\"\n"],
+ 'CR inside, after a valid header' => [
+ "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\"X-A: a\\r\\rb\\n\"\n\nmsgid \"id\"\nmsgstr \"v\"\n",
+ ],
+ ];
+ }
+
+ public function testFromPoFileNamesTheFileOfAHeaderWithALineBreak(): void
+ {
+ $file = $this->temporaryDirectory() . '/header_de.po';
+ file_put_contents($file, "msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n");
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/^Invalid PO file ".*header_de\.po": The value of header "X-A" contains a line break\.$/');
+ TranslationCatalog::fromPoFile($file);
+ }
+
+ /**
+ * Boundary: line breaks the loader removes itself leave a valid header - a CR at either end is
+ * trimmed, and an escaped LF inside a value cannot survive loading at all, because the header
+ * block is split at LF and a line without "Name:" is appended to the previous header
+ * (characterization of gettext/gettext: "a\nb" is read as "ab").
+ */
+ #[DataProvider('headersLosingTheirLineBreakWhenLoaded')]
+ public function testAHeaderWhoseLineBreakIsRemovedWhenLoadedStaysValid(string $header_line, string $expected): void
+ {
+ $catalog = TranslationCatalog::fromPoString("msgid \"\"\r\nmsgstr \"\"\r\n" . $header_line . "\r\n");
+
+ $this->assertSame(['X-A' => $expected], $catalog->getHeaders());
+ $this->assertSame("msgid \"\"\nmsgstr \"\"\n\"X-A: " . $expected . "\\n\"\n", $catalog->toPoString());
+ }
+
+ public static function headersLosingTheirLineBreakWhenLoaded(): array
+ {
+ return [
+ 'trailing CR' => ['"X-A: tail\\r\\n"', 'tail'],
+ 'leading CR' => ['"X-A: \\rhead\\n"', 'head'],
+ 'escaped LF inside' => ['"X-A: a\\nb\\n"', 'ab'],
+ 'no line break' => ['"X-A: v\\n"', 'v'],
+ ];
+ }
+
+ public function testTheWriterLayoutMatchesTheShippedFiles(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Language', 'de');
+ $catalog->add(self::entry('tos', 'tos_agreement', "Zeile 1\nZeile 2"));
+ $catalog->add(self::entry('tos', 'tos_empty', ''));
+
+ $this->assertSame(
+ "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\n"
+ . "msgctxt \"tos\"\nmsgid \"tos_agreement\"\nmsgstr \"\"\n\"Zeile 1\\n\"\n\"Zeile 2\"\n\n"
+ . "msgctxt \"tos\"\nmsgid \"tos_empty\"\nmsgstr \"\"\n",
+ $catalog->toPoString()
+ );
+ }
+
+ /**
+ * Property: every shipped `.po`/`.pot` of the migrated tos module is read by fromPoFile() and
+ * re-serialized byte-identically (convert_module_to_po.php and the overlay rely on that), and
+ * its `.mo` serves exactly the non-empty translations of the `.po`.
+ */
+ public function testEveryShippedTosFileRoundTripsByteIdentically(): void
+ {
+ $files = glob(self::TOS_LANG_DIRECTORY . '/*.{po,pot}', GLOB_BRACE) ?: [];
+ $this->assertNotSame([], $files, 'the shipped tos files were not found');
+
+ foreach ($files as $file) {
+ $catalog = TranslationCatalog::fromPoFile($file);
+ $this->assertSame(file_get_contents($file), $catalog->toPoString(), basename($file));
+
+ $expected = [];
+ foreach ($catalog->getEntries() as $entry) {
+ // written without msgctxt by convert_module_to_po.php (the module is the directory's)
+ $this->assertNull($entry->getContext(), basename($file));
+ if ($entry->getTranslation() !== '') {
+ $expected[$entry->getId()] = $entry->getTranslation();
+ }
+ }
+ $actual = \MigratedPoFixture::readMoTranslations($catalog->toMoString());
+ ksort($expected, SORT_STRING);
+ ksort($actual, SORT_STRING);
+ $this->assertSame($expected, $actual, basename($file));
+ }
+ }
+
+ public function testReadsMultilineStringsCrLfLineEndingsAndABom(): void
+ {
+ $po = "\xEF\xBB\xBFmsgid \"\"\r\nmsgstr \"\"\r\n\"Language: de\\n\"\r\n\r\n"
+ . "msgctxt \"mod\"\r\nmsgid \"id\"\r\nmsgstr \"\"\r\n\"eins \"\r\n\"zwei\"\r\n";
+
+ $catalog = TranslationCatalog::fromPoString($po);
+
+ $this->assertSame('de', $catalog->getHeader('Language'));
+ $this->assertSame('eins zwei', $catalog->find('mod', 'id')?->getTranslation());
+ $this->assertStringStartsWith("msgid \"\"\nmsgstr \"\"\n", $catalog->toPoString(), 'no BOM, LF only');
+ }
+
+ /**
+ * A BOM that is not at the very start is not skipped - it is data, i.e. a syntax error here.
+ */
+ public function testOnlyALeadingBomIsSkipped(): void
+ {
+ $this->expectException(RuntimeException::class);
+ TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"b\"\n\xEF\xBB\xBFmsgid \"c\"\nmsgstr \"d\"\n");
+ }
+
+ /**
+ * Every value is used as UTF-8 as it is - no charset conversion takes place anywhere. Content
+ * that is not valid UTF-8 at all (e.g. Latin-1 encoded bytes carrying a translation) must
+ * therefore be rejected outright, before the underlying loader even attempts to parse it.
+ */
+ public function testRejectsContentThatIsNotValidUtf8(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('not valid UTF-8');
+ // "\xE4" (a bare Latin-1 "ä") is not a valid UTF-8 byte sequence on its own.
+ TranslationCatalog::fromPoString("msgid \"id\"\nmsgstr \"caf\xE4\"\n");
+ }
+
+ /**
+ * A `.po` can be valid UTF-8 while its "Content-Type" header declares another charset (e.g. copied from a
+ * Latin-1-era file). Values are read and written as UTF-8 verbatim, so honoring that declaration would mislabel the
+ * content: any charset other than UTF-8 is rejected.
+ */
+ public function testRejectsAContentTypeHeaderDeclaringAnyCharsetOtherThanUtf8(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unsupported charset "ISO-8859-1"');
+ TranslationCatalog::fromPoString(
+ "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n\n"
+ . "msgid \"id\"\nmsgstr \"value\"\n"
+ );
+ }
+
+ public function testDecodesControlEscapes(): void
+ {
+ $catalog = TranslationCatalog::fromPoString("msgid \"id\"\nmsgstr \"\\101\\x42\\a\\b\\f\\v\\t\\r\\n\"\n");
+
+ $this->assertSame("AB\x07\x08\x0c\x0b\t\r\n", $catalog->find(null, 'id')?->getTranslation());
+ }
+
+ /**
+ * Obsolete messages are dropped on load - and therefore never written back.
+ */
+ public function testDropsObsoleteMessages(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgid \"id\"\nmsgstr \"v\"\n\n#~ msgctxt \"mod\"\n#~ msgid \"gone\"\n#~ msgstr \"weg\"\n"
+ );
+
+ $this->assertCount(1, $catalog->getEntries());
+ $this->assertNull($catalog->find('mod', 'gone'));
+ $this->assertNull($catalog->find(null, 'gone'));
+ $this->assertStringNotContainsString('gone', $catalog->toPoString());
+ }
+
+ /**
+ * References and previous-message lines have no API any more, but are carried along untouched.
+ */
+ public function testReferencesAndPreviousMessageLinesAreWrittenBackAsLoaded(): void
+ {
+ $po = "msgid \"\"\nmsgstr \"\"\n\n#: file.php:12\n#: other.php\n#, fuzzy\n#| msgid \"old\"\nmsgid \"id\"\nmsgstr \"v\"\n";
+
+ $catalog = TranslationCatalog::fromPoString($po);
+
+ $this->assertSame([], $catalog->find(null, 'id')?->getTranslatorComments());
+ $this->assertSame($po, $catalog->toPoString());
+ }
+
+ public function testTheSameIdInDifferentContextsIsNoDuplicate(): void
+ {
+ $catalog = TranslationCatalog::fromPoString(
+ "msgctxt \"a\"\nmsgid \"id\"\nmsgstr \"A\"\n\nmsgctxt \"b\"\nmsgid \"id\"\nmsgstr \"B\"\n\nmsgid \"id\"\nmsgstr \"ohne\"\n"
+ );
+
+ $this->assertSame('A', $catalog->find('a', 'id')?->getTranslation());
+ $this->assertSame('B', $catalog->find('b', 'id')?->getTranslation());
+ $this->assertSame('ohne', $catalog->find(null, 'id')?->getTranslation());
+ $this->assertSame(['A', 'B', 'ohne'], array_map(
+ static fn(TranslationEntry $entry): string => $entry->getTranslation(),
+ $catalog->getEntries()
+ ));
+ }
+
+ /**
+ * An empty file or one without a header block is a valid (empty or header-less) catalog.
+ */
+ #[DataProvider('validButEmptyPo')]
+ public function testAnEmptyOrHeaderlessPoIsNoError(string $po, int $expected_entries): void
+ {
+ $catalog = TranslationCatalog::fromPoString($po);
+
+ $this->assertCount($expected_entries, $catalog->getEntries());
+ $this->assertSame([], $catalog->getHeaders());
+ }
+
+ public static function validButEmptyPo(): array
+ {
+ return [
+ 'empty' => ['', 0],
+ 'only a BOM' => ["\xEF\xBB\xBF", 0],
+ 'only blank lines' => ["\n\r\n\n", 0],
+ 'only a comment' => ["# just a comment\n", 0],
+ 'no header' => ["msgid \"a\"\nmsgstr \"b\"\n", 1],
+ 'empty header' => ["msgid \"\"\nmsgstr \"\"\n", 0],
+ ];
+ }
+
+ /**
+ * Everything StrictPoLoader rejects - and all of it as RuntimeException (the library's own
+ * exceptions are plain \Exception, which no caller would catch).
+ */
+ #[DataProvider('brokenPo')]
+ public function testThrowsARuntimeExceptionForBrokenContent(string $po): void
+ {
+ $this->expectException(RuntimeException::class);
+ TranslationCatalog::fromPoString($po);
+ }
+
+ public static function brokenPo(): array
+ {
+ $mo_catalog = new TranslationCatalog();
+ $mo_catalog->add(self::entry('mod', 'id', 'v'));
+
+ return [
+ 'unterminated string' => ["msgid \"abc\nmsgstr \"\"\n"],
+ 'escaped closing quote' => ["msgid \"abc\\\"\nmsgstr \"\"\n"],
+ 'unquoted value' => ["msgid abc\nmsgstr \"\"\n"],
+ 'unknown keyword' => ["msgfoo \"abc\"\n"],
+ 'keyword without value' => ["msgid\n"],
+ 'continuation without keyword' => ["\"dangling\"\n"],
+ 'text after the closing quote' => ["msgid \"a\" trailing\nmsgstr \"\"\n"],
+ 'invalid escape sequence' => ["msgid \"a\\q\"\nmsgstr \"\"\n"],
+ 'octal escape above 127' => ["msgid \"a\"\nmsgstr \"\\303\\244\"\n"],
+ 'missing msgstr before the next message' => ["msgid \"a\"\n\nmsgid \"b\"\nmsgstr \"B\"\n"],
+ 'missing msgstr at the end' => ["msgid \"a\"\nmsgstr \"A\"\n\nmsgid \"b\"\n"],
+ 'msgstr without msgid' => ["msgstr \"A\"\n"],
+ 'duplicate message' => ["msgid \"a\"\nmsgstr \"first\"\n\nmsgid \"a\"\nmsgstr \"second\"\n"],
+ 'duplicate message with context' => ["msgctxt \"m\"\nmsgid \"a\"\nmsgstr \"1\"\n\nmsgctxt \"m\"\nmsgid \"a\"\nmsgstr \"2\"\n"],
+ 'gap in the plural forms' => ["msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"x\"\nmsgstr[2] \"z\"\n"],
+ 'plural forms not starting at 0' => ["msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[1] \"y\"\n"],
+ 'binary data' => ["\x00\x01\x02\xff\xfe binary"],
+ 'a .mo file' => [$mo_catalog->toMoString()],
+ 'truncated inside a string' => ["msgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"Hal"],
+ 'truncated inside a keyword' => ["msgctxt \"mod\"\nmsgid \"id\"\nmsg"],
+ 'truncated after msgctxt' => ["msgctxt \"mod\"\n"],
+ ];
+ }
+
+ public function testTheLineOfTheErrorIsReported(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/line 3\b/');
+ TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"A\"\nbroken\n");
+ }
+
+ /**
+ * The adapter's temporary error handler must be removed again on success and on failure -
+ * otherwise every later warning of the request would be turned into an exception.
+ */
+ public function testTheErrorHandlerIsRestoredAfterReading(): void
+ {
+ $handler = static fn(): bool => false;
+ set_error_handler($handler);
+ try {
+ TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"b\"\n");
+ try {
+ TranslationCatalog::fromPoString('broken');
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException) {
+ }
+
+ $current = set_error_handler(null);
+ restore_error_handler();
+ $this->assertSame($handler, $current);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ public function testFromPoFileReadsAFile(): void
+ {
+ $file = $this->temporaryDirectory() . '/mod_de.po';
+ file_put_contents($file, "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\nmsgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"v\"\n");
+
+ $catalog = TranslationCatalog::fromPoFile($file);
+
+ $this->assertSame('de', $catalog->getHeader('Language'));
+ $this->assertSame('v', $catalog->find('mod', 'id')?->getTranslation());
+ }
+
+ public function testFromPoFileNamesTheFileOfABrokenPo(): void
+ {
+ $file = $this->temporaryDirectory() . '/broken_de.po';
+ file_put_contents($file, "msgid \"a\"\nmsgstr \"A\"\nbroken\n");
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/^Invalid PO file ".*broken_de\.po": .*line 3\b/');
+ TranslationCatalog::fromPoFile($file);
+ }
+
+ public function testFromPoFileThrowsForAMissingFile(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Could not read PO file/');
+ TranslationCatalog::fromPoFile($this->temporaryDirectory() . '/missing.po');
+ }
+
+ public function testFromPoFileThrowsForADirectory(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Could not read PO file/');
+ TranslationCatalog::fromPoFile($this->temporaryDirectory());
+ }
+
+ public function testFromPoFileThrowsForAnUnreadableFile(): void
+ {
+ $file = $this->temporaryDirectory() . '/unreadable.po';
+ file_put_contents($file, "msgid \"a\"\nmsgstr \"b\"\n");
+ chmod($file, 0000);
+ if (is_readable($file)) {
+ $this->markTestSkipped('File permissions are not enforced for this user (root).');
+ }
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Could not read PO file/');
+ TranslationCatalog::fromPoFile($file);
+ }
+
+ /**
+ * MigratedLanguageFileSync seeds a new overlay from a clone of the shipped catalog and relies on
+ * the shipped one keeping its values; a shallow clone would share the messages.
+ */
+ public function testCloneIsIndependentOfTheOriginal(): void
+ {
+ $original = self::singleEntryCatalog(self::entry('common', 'save', 'Speichern'));
+ $original->setHeader('Language', 'de');
+ $original_po = $original->toPoString();
+
+ $clone = clone $original;
+ $cloned_entry = $clone->find('common', 'save');
+ $cloned_entry->translate('Sichern');
+ $cloned_entry->addFlag('fuzzy');
+ $cloned_entry->setExtractedComments(['changed']);
+ $clone->add($cloned_entry);
+ $clone->add(self::entry('common', 'cancel', 'Abbrechen'));
+ $clone->setHeader('Language', 'en');
+
+ $this->assertSame($original_po, $original->toPoString());
+ $this->assertSame('Speichern', $original->find('common', 'save')->getTranslation());
+ $this->assertNull($original->find('common', 'cancel'));
+ $this->assertSame('Sichern', $clone->find('common', 'save')->getTranslation());
+ $this->assertSame('en', $clone->getHeader('Language'));
+ }
+
+ /**
+ * @return list the ids in the order the catalog holds them
+ */
+ private static function idsOf(TranslationCatalog $catalog): array
+ {
+ return array_map(static fn(TranslationEntry $entry): string => $entry->getId(), $catalog->getEntries());
+ }
+
+ private static function catalogOfIds(string ...$ids): TranslationCatalog
+ {
+ $catalog = new TranslationCatalog();
+ foreach ($ids as $id) {
+ $catalog->add(self::entry('common', $id, $id));
+ }
+
+ return $catalog;
+ }
+
+ public function testAddInIdOrderInsertsAtTheFront(): void
+ {
+ $catalog = self::catalogOfIds('m', 'z');
+
+ $catalog->addInIdOrder(self::entry('common', 'a', 'A'));
+
+ $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
+ }
+
+ public function testAddInIdOrderInsertsInTheMiddle(): void
+ {
+ $catalog = self::catalogOfIds('a', 'z');
+
+ $catalog->addInIdOrder(self::entry('common', 'm', 'M'));
+
+ $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
+ }
+
+ public function testAddInIdOrderInsertsAtTheEnd(): void
+ {
+ $catalog = self::catalogOfIds('a', 'm');
+
+ $catalog->addInIdOrder(self::entry('common', 'z', 'Z'));
+
+ $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
+ }
+
+ /**
+ * An entry with an id (and context) the catalog already has is replaced in place, like add() -
+ * neither removed and re-appended nor duplicated.
+ */
+ public function testAddInIdOrderReplacesAnExistingEntryInPlace(): void
+ {
+ $catalog = self::catalogOfIds('a', 'm', 'z');
+
+ $catalog->addInIdOrder(self::entry('common', 'm', 'replaced'));
+
+ $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
+ $this->assertSame('replaced', $catalog->find('common', 'm')->getTranslation());
+ }
+
+ public function testAddInIdOrderOnAnEmptyCatalogAddsTheOnlyEntry(): void
+ {
+ $catalog = new TranslationCatalog();
+
+ $catalog->addInIdOrder(self::entry('common', 'a', 'A'));
+
+ $this->assertSame(['a'], self::idsOf($catalog));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationEntryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationEntryTest.php
new file mode 100644
index 000000000000..cc3f266906d8
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationEntryTest.php
@@ -0,0 +1,286 @@
+translate($translation);
+
+ return $entry;
+ }
+
+ /**
+ * @return list
+ */
+ private static function describe(TranslationCatalog $catalog): array
+ {
+ return array_map(
+ static fn(TranslationEntry $entry): array => [$entry->getContext(), $entry->getId(), $entry->getTranslation()],
+ $catalog->getEntries()
+ );
+ }
+
+ public function testANewEntryIsEmpty(): void
+ {
+ $entry = new TranslationEntry(null, 'id');
+
+ $this->assertNull($entry->getContext());
+ $this->assertSame('id', $entry->getId());
+ $this->assertSame('', $entry->getTranslation());
+ $this->assertSame([], $entry->getTranslatorComments());
+ $this->assertSame([], $entry->getExtractedComments());
+ $this->assertSame([], $entry->getFlags());
+ }
+
+ public function testTranslateReplacesTheTranslation(): void
+ {
+ $entry = self::entry('mod', 'id', 'alt');
+ $entry->translate('0');
+
+ $this->assertSame('mod', $entry->getContext());
+ $this->assertSame('0', $entry->getTranslation());
+ }
+
+ /**
+ * Flags are deduplicated, kept sorted by the library, an empty flag is ignored and removing an
+ * absent one is a no-op.
+ */
+ public function testFlagsAreSortedDeduplicatedEmptyOnesIgnoredAndRemovable(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+ $entry->addFlag('php-format');
+ $entry->addFlag('fuzzy');
+ $entry->addFlag('fuzzy');
+ $entry->addFlag('');
+
+ $this->assertSame(['fuzzy', 'php-format'], $entry->getFlags());
+
+ $entry->removeFlag('fuzzy');
+ $entry->removeFlag('not-set');
+
+ $this->assertSame(['php-format'], $entry->getFlags());
+ $this->assertFalse($entry->hasFlag('fuzzy'));
+ $this->assertTrue($entry->hasFlag('php-format'));
+ }
+
+ public function testRemoveTranslatorCommentsStartingWithOnlyRemovesMatchingCommentsAndKeepsTheOrder(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+ $entry->addTranslatorComment('original: a');
+ $entry->addTranslatorComment('note');
+ $entry->addTranslatorComment('original: b');
+ $entry->addTranslatorComment(' original: indented');
+ $entry->addTranslatorComment('original:no space');
+ $entry->addTranslatorComment('last');
+
+ $entry->removeTranslatorCommentsStartingWith('original: ');
+
+ $this->assertSame(['note', ' original: indented', 'original:no space', 'last'], $entry->getTranslatorComments());
+ }
+
+ /**
+ * Characterization of gettext/gettext: comments are a set compared loosely - an equal comment,
+ * but also a numerically equal one ("1" == "01" == "1.0"), is not added a second time. The
+ * component's own comments ("original: ...", "local_change: ...") are never numeric.
+ */
+ public function testAnEqualCommentIsNotStoredTwice(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+ $entry->addTranslatorComment('note');
+ $entry->addTranslatorComment('note');
+ $entry->addTranslatorComment('1');
+ $entry->addTranslatorComment('01');
+ $entry->addTranslatorComment('1.0');
+ $entry->addExtractedComment('ext');
+ $entry->addExtractedComment('ext');
+
+ $this->assertSame(['note', '1'], $entry->getTranslatorComments());
+ $this->assertSame(['ext'], $entry->getExtractedComments());
+ }
+
+ public function testSetExtractedCommentsReplacesAllOfThemAsSingleLines(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+ $entry->addExtractedComment('old');
+ $entry->addTranslatorComment('translator');
+
+ $entry->setExtractedComments(['b', "c\nd", 'a']);
+
+ $this->assertSame(['b', 'c d', 'a'], $entry->getExtractedComments());
+ $this->assertSame(['translator'], $entry->getTranslatorComments(), 'translator comments are separate');
+
+ $entry->setExtractedComments([]);
+
+ $this->assertSame([], $entry->getExtractedComments());
+ }
+
+ /**
+ * An extracted comment (`#.`) may come from an admin remark (see ShippedPoMerger): control characters and bidi
+ * override/embedding/isolate characters become a space, not just the line breaks singleLine() handles; a translator
+ * comment ("original: ...") is only normalised to a single line.
+ */
+ public function testAddExtractedCommentReplacesControlAndBidiCharactersWithASpace(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+
+ $entry->addExtractedComment("a\tb\u{202E}c\u{2066}d");
+
+ $this->assertSame(['a b c d'], $entry->getExtractedComments());
+ }
+
+ public function testAddTranslatorCommentKeepsControlCharactersOtherThanLineBreaks(): void
+ {
+ $entry = new TranslationEntry('mod', 'id');
+
+ $entry->addTranslatorComment("original: a\tb");
+
+ $this->assertSame(["original: a\tb"], $entry->getTranslatorComments());
+ }
+
+ public function testFindDistinguishesContexts(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(self::entry(null, 'id', 'ohne'));
+ $catalog->add(self::entry('mod', 'id', 'mod'));
+
+ $this->assertSame('ohne', $catalog->find(null, 'id')?->getTranslation());
+ $this->assertSame('mod', $catalog->find('mod', 'id')?->getTranslation());
+ $this->assertNull($catalog->find('other', 'id'));
+ $this->assertNull($catalog->find('mod', 'ID'));
+ }
+
+ public function testGetEntriesKeepsTheInsertionOrder(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(self::entry('mod', 'b', 'B'));
+ $catalog->add(self::entry('mod', '10', 'zehn'));
+ $catalog->add(self::entry('mod', 'a', 'A'));
+
+ $this->assertSame([['mod', 'b', 'B'], ['mod', '10', 'zehn'], ['mod', 'a', 'A']], self::describe($catalog));
+ }
+
+ public function testAddReplacesAnEntryWithTheSameContextAndIdKeepingItsPosition(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(self::entry('mod', 'a', 'alt'));
+ $catalog->add(self::entry('mod', 'b', 'B'));
+ $catalog->add(self::entry('other', 'a', 'fremd'));
+
+ $catalog->add(self::entry('mod', 'a', 'neu'));
+
+ $this->assertSame([['mod', 'a', 'neu'], ['mod', 'b', 'B'], ['other', 'a', 'fremd']], self::describe($catalog));
+ }
+
+ /**
+ * add() does not copy: MigratedLanguageFileSync takes entries of one catalog, changes and adds
+ * them to another one - and relies on find() returning the message itself.
+ */
+ public function testAnAddedEntryIsSharedNotCopied(): void
+ {
+ $first = new TranslationCatalog();
+ $second = new TranslationCatalog();
+ $entry = self::entry('mod', 'a', 'alt');
+ $first->add($entry);
+ $second->add($entry);
+
+ $entry->translate('nach add');
+ $first->find('mod', 'a')?->addFlag('fuzzy');
+
+ $this->assertSame('nach add', $first->find('mod', 'a')?->getTranslation());
+ $this->assertSame('nach add', $second->find('mod', 'a')?->getTranslation());
+ $this->assertTrue($entry->hasFlag('fuzzy'));
+ $this->assertTrue($second->find('mod', 'a')?->hasFlag('fuzzy'));
+ }
+
+ /**
+ * Two catalogs read from the same file are independent (sync() reads the shipped file twice
+ * for exactly that reason: one copy is modified, the other one is the comparison baseline).
+ */
+ public function testCatalogsReadFromTheSameContentAreIndependent(): void
+ {
+ $po = "msgctxt \"mod\"\nmsgid \"a\"\nmsgstr \"shipped\"\n";
+ $baseline = TranslationCatalog::fromPoString($po);
+ $modified = TranslationCatalog::fromPoString($po);
+
+ $modified->find('mod', 'a')?->translate('changed');
+
+ $this->assertSame('shipped', $baseline->find('mod', 'a')?->getTranslation());
+ }
+
+ /**
+ * withContext() returns an independent COPY with the new context - the original entry itself
+ * (context, id) is left completely unchanged.
+ */
+ public function testWithContextReturnsACopyLeavingTheOriginalEntryUnchanged(): void
+ {
+ $original = self::entry('mod', 'greeting', 'Hallo');
+
+ $copy = $original->withContext(null);
+
+ $this->assertSame('mod', $original->getContext());
+ $this->assertSame('greeting', $original->getId());
+ $this->assertNull($copy->getContext());
+ $this->assertSame('greeting', $copy->getId());
+ }
+
+ /**
+ * Everything besides the context - the translation, comments, extracted comments and flags -
+ * survives withContext() untouched (see MigratedLanguageFileSync::syncLocked(), which relies on
+ * this to migrate a legacy overlay entry that carried the module as msgctxt onto the new,
+ * msgctxt-less shape without losing its bookkeeping).
+ */
+ public function testWithContextPreservesTranslationCommentsAndFlags(): void
+ {
+ $original = self::entry('mod', 'greeting', 'Hallo');
+ $original->addTranslatorComment('original: Hello');
+ $original->addExtractedComment('shown on the login page');
+ $original->addFlag('fuzzy');
+
+ $copy = $original->withContext(null);
+
+ $this->assertSame('Hallo', $copy->getTranslation());
+ $this->assertSame(['original: Hello'], $copy->getTranslatorComments());
+ $this->assertSame(['shown on the login page'], $copy->getExtractedComments());
+ $this->assertTrue($copy->hasFlag('fuzzy'));
+ }
+
+ public function testWithContextCanSetAContextOnAContextlessEntry(): void
+ {
+ $original = self::entry(null, 'greeting', 'Hallo');
+
+ $copy = $original->withContext('mod');
+
+ $this->assertNull($original->getContext());
+ $this->assertSame('mod', $copy->getContext());
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php
new file mode 100644
index 000000000000..f721ee6b4329
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php
@@ -0,0 +1,125 @@
+getPath();
+ $second_call = $directory->getPath();
+ $third_call = $directory->getPath();
+
+ self::assertSame($expected_path, $first_call);
+ self::assertSame($first_call, $second_call);
+ self::assertSame($first_call, $third_call);
+ }
+
+ public function testGetPathAppendsTheConfiguredPathInsideComponentToTheBaseDirectory(): void
+ {
+ $component = new class () implements Component {
+ public function init(
+ array|\ArrayAccess &$define,
+ array|\ArrayAccess &$implement,
+ array|\ArrayAccess &$use,
+ array|\ArrayAccess &$contribute,
+ array|\ArrayAccess &$seek,
+ array|\ArrayAccess &$provide,
+ array|\ArrayAccess &$pull,
+ array|\ArrayAccess &$internal,
+ ): void {
+ }
+ };
+
+ $directory = new ComponentLanguageFileDirectory($component, 'tst', 'custom/lang/path/');
+
+ self::assertStringEndsWith('/custom/lang/path/', $directory->getPath());
+ }
+
+ private function anyComponent(): Component
+ {
+ return new class () implements Component {
+ public function init(
+ array|\ArrayAccess &$define,
+ array|\ArrayAccess &$implement,
+ array|\ArrayAccess &$use,
+ array|\ArrayAccess &$contribute,
+ array|\ArrayAccess &$seek,
+ array|\ArrayAccess &$provide,
+ array|\ArrayAccess &$pull,
+ array|\ArrayAccess &$internal,
+ ): void {
+ }
+ };
+ }
+
+ public function testDefaultShippedFileNamePatternIsPrefixUnderscore(): void
+ {
+ $directory = new ComponentLanguageFileDirectory($this->anyComponent(), 'tos');
+
+ self::assertSame('tos_%s', $directory->getShippedFileNamePattern());
+ }
+
+ public function testACustomShippedFileNamePatternIsUsedAsGiven(): void
+ {
+ $directory = new ComponentLanguageFileDirectory($this->anyComponent(), 'tos', 'lang/', 'ilias_%s');
+
+ self::assertSame('ilias_%s', $directory->getShippedFileNamePattern());
+ }
+
+ public function testAnInvalidCustomShippedFileNamePatternIsRejectedByTheConstructor(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ new ComponentLanguageFileDirectory($this->anyComponent(), 'tos', 'lang/', 'no-placeholder-at-all');
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/DuplicateIdentifierMessageTest.php b/components/ILIAS/Language/tests/ComponentTranslation/DuplicateIdentifierMessageTest.php
new file mode 100644
index 000000000000..79d028b18211
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/DuplicateIdentifierMessageTest.php
@@ -0,0 +1,118 @@
+createStub(Language::class);
+ $lng->method('txt')->willReturn($text);
+
+ return $lng;
+ }
+
+ private function refinery(): Refinery
+ {
+ return new Refinery($this->createStub(DataFactory::class), $this->createStub(Language::class));
+ }
+
+ public function testAnEntryNamesItsKeyAndWhatItWouldDuplicate(): void
+ {
+ $this->assertSame(
+ 'tos#:#x (common: x, mme: X)',
+ DuplicateIdentifierMessage::entry('tos#:#x', ['common: x', 'mme: X'])
+ );
+ }
+
+ public function testTheMessageEscapesTheKeysAndFillsThePlaceholder(): void
+ {
+ $message = DuplicateIdentifierMessage::of(
+ $this->lng('Not unique: %s'),
+ $this->refinery(),
+ ['a#:# (x: "")', 'c#:#d (e: d)']
+ );
+
+ $this->assertSame('Not unique: a#:#<b> (x: "<b>"); c#:#d (e: d)', $message);
+ }
+
+ /** A translation without the placeholder, or with another "%", still names the entries and does not fail. */
+ public function testATextWithoutPlaceholderGetsTheListAppendedAndAFurtherPercentSignIsKept(): void
+ {
+ $this->assertSame(
+ 'Not unique 100% right. a (b: a)',
+ DuplicateIdentifierMessage::of($this->lng('Not unique 100% right.'), $this->refinery(), ['a (b: a)'])
+ );
+ $this->assertSame(
+ '100% unique: a (b: a)',
+ DuplicateIdentifierMessage::of($this->lng('100% unique: %s'), $this->refinery(), ['a (b: a)'])
+ );
+ }
+
+ /** The keys come from an uploaded file: one entry is cut to 200 characters, the list to the first entries of PlainLogText. */
+ public function testEntriesAreShortenedAndTheListIsLimited(): void
+ {
+ $long = str_repeat('k', 300);
+ $entries = [$long];
+ for ($i = 1; $i <= PlainLogText::MAX_LISTED_KEYS + 4; $i++) {
+ $entries[] = 'e' . $i;
+ }
+
+ $message = DuplicateIdentifierMessage::of($this->lng('%s'), $this->refinery(), $entries);
+
+ $this->assertStringStartsWith(str_repeat('k', 199) . '…; e1; ', $message);
+ $this->assertStringNotContainsString(str_repeat('k', 200), $message);
+ $this->assertStringContainsString('… (+5)', $message);
+ $this->assertStringNotContainsString('e' . (PlainLogText::MAX_LISTED_KEYS + 4), $message);
+ }
+
+ public function testTheLogLineHoldsAtMostTwoHundredShortenedEntriesAndCountsTheRest(): void
+ {
+ $entries = [str_repeat('k', 300)];
+ for ($i = 1; $i < 250; $i++) {
+ $entries[] = 'e' . $i;
+ }
+
+ $line = DuplicateIdentifierMessage::forLog($entries);
+
+ $this->assertStringContainsString(str_repeat('k', 199) . '…; e1; ', $line);
+ $this->assertStringNotContainsString(str_repeat('k', 200), $line);
+ $this->assertStringContainsString('e199', $line);
+ $this->assertStringNotContainsString('e200;', $line);
+ $this->assertStringEndsWith(' … (+50)', $line);
+ }
+
+ public function testTheLogLineWithFewEntriesHasNoRemainderAndNoControlCharacters(): void
+ {
+ $line = DuplicateIdentifierMessage::forLog(["a#:#b\x1b[31m (c: b)"]);
+
+ $this->assertStringContainsString('a#:#b?[31m (c: b)', $line);
+ $this->assertStringNotContainsString('(+', $line);
+ $this->assertStringNotContainsString("\x1b", $line);
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/IdentifierUniquenessTest.php b/components/ILIAS/Language/tests/ComponentTranslation/IdentifierUniquenessTest.php
new file mode 100644
index 000000000000..9b440c1510ea
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/IdentifierUniquenessTest.php
@@ -0,0 +1,257 @@
+ every query */
+ private array $queries = [];
+
+ /**
+ * lng_data answers case-insensitively like its collation; without a WHERE it hands out every row.
+ *
+ * @param list $lng_data
+ */
+ private function db(array $lng_data): \ilDBInterface
+ {
+ $chunk = null;
+ $rows_of = [];
+ $db = $this->createStub(\ilDBInterface::class);
+ $db->method('in')->willReturnCallback(static function (string $field, array $values) use (&$chunk): string {
+ $chunk = array_map(mb_strtolower(...), $values);
+
+ return 'identifier IN (…)';
+ });
+ $db->method('query')->willReturnCallback(function (string $sql) use (&$chunk, &$rows_of, $lng_data): \ilDBStatement {
+ $this->queries[] = $sql;
+ $statement = $this->createStub(\ilDBStatement::class);
+ $with_where = str_contains($sql, ' WHERE ');
+ $rows_of[spl_object_id($statement)] = $with_where
+ ? array_values(array_filter(
+ $lng_data,
+ static fn(array $row): bool => in_array(mb_strtolower($row['identifier']), $chunk ?? [], true)
+ ))
+ : $lng_data;
+
+ return $statement;
+ });
+ $db->method('fetchAssoc')->willReturnCallback(
+ static function (\ilDBStatement $statement) use (&$rows_of): ?array {
+ return array_shift($rows_of[spl_object_id($statement)]);
+ }
+ );
+
+ return $db;
+ }
+
+ /**
+ * @param list $lng_data
+ * @return IdentifierUniqueness
+ */
+ private function uniqueness(array $lng_data = []): IdentifierUniqueness
+ {
+ return new IdentifierUniqueness($this->db($lng_data));
+ }
+
+ public function testAnIdentifierOfAnotherModuleInTheDatabaseIsADuplicate(): void
+ {
+ $uniqueness = $this->uniqueness([['module' => 'mme', 'identifier' => 'login'], ['module' => 'mme', 'identifier' => 'other']]);
+
+ $this->assertSame(
+ ['common#:#login' => ['mme: login']],
+ $uniqueness->findDuplicatesAmong([
+ 'common#:#login' => ['module' => 'common', 'identifier' => 'login'],
+ 'common#:#free' => ['module' => 'common', 'identifier' => 'free'],
+ ])
+ );
+ }
+
+ public function testAnOtherSpellingIsADuplicateAndNamedAsStored(): void
+ {
+ $uniqueness = $this->uniqueness([['module' => 'mme', 'identifier' => 'login']]);
+
+ $this->assertSame(
+ [0 => ['mme: login']],
+ $uniqueness->findDuplicatesAmong([['module' => 'common', 'identifier' => 'Login']])
+ );
+ }
+
+ /** The module holds "login" in another spelling than the entry: that is a duplicate, the module name is not case-sensitive. */
+ public function testTheSpellingCountsInTheOwnModuleButNotTheCaseOfTheModuleName(): void
+ {
+ $this->assertSame(
+ [0 => ['common: login']],
+ $this->uniqueness([['module' => 'common', 'identifier' => 'login']])
+ ->findDuplicatesAmong([['module' => 'common', 'identifier' => 'Login']])
+ );
+ $this->assertSame(
+ [],
+ $this->uniqueness([['module' => 'COMMON', 'identifier' => 'login']])
+ ->findDuplicatesAmong([['module' => 'common', 'identifier' => 'login']])
+ );
+ }
+
+ public function testAnOldCollisionOfTheOwnModuleInTheSameSpellingIsNoDuplicate(): void
+ {
+ $uniqueness = $this->uniqueness([['module' => 'mme', 'identifier' => 'login'], ['module' => 'COMMON', 'identifier' => 'login']]);
+
+ $this->assertSame([], $uniqueness->findDuplicatesAmong([['module' => 'common', 'identifier' => 'login']]));
+ }
+
+ public function testTheSameIdentifierInTwoModulesOfOneCallIsADuplicateOfBoth(): void
+ {
+ $this->assertSame(
+ ['a#:#x' => ['b: x', 'c: X'], 'b#:#x' => ['a: x', 'c: X'], 'c#:#X' => ['a: x', 'b: x']],
+ $this->uniqueness()->findDuplicatesAmong([
+ 'a#:#x' => ['module' => 'a', 'identifier' => 'x'],
+ 'b#:#x' => ['module' => 'b', 'identifier' => 'x'],
+ 'c#:#X' => ['module' => 'c', 'identifier' => 'X'],
+ 'a#:#y' => ['module' => 'a', 'identifier' => 'y'],
+ ])
+ );
+ }
+
+ public function testTheFormsOfOnePluralMessageAreNoDuplicatesOfEachOtherButOfAnotherModulesIdentifier(): void
+ {
+ $entries = [
+ 'poll#:#item [1]' => ['module' => 'poll', 'identifier' => 'item [1]'],
+ 'poll#:#item [2]' => ['module' => 'poll', 'identifier' => 'item [2]'],
+ ];
+
+ $this->assertSame([], $this->uniqueness()->findDuplicatesAmong($entries));
+ $this->assertSame(
+ ['poll#:#item [1]' => ['mme: item'], 'poll#:#item [2]' => ['mme: item']],
+ $this->uniqueness([['module' => 'mme', 'identifier' => 'item']])->findDuplicatesAmong($entries)
+ );
+ }
+
+ public function testNoEntriesAskNothing(): void
+ {
+ $db = $this->createMock(\ilDBInterface::class);
+ $db->expects($this->never())->method('query');
+
+ $this->assertSame([], (new IdentifierUniqueness($db))->findDuplicatesAmong([]));
+ }
+
+ /** Up to 1000 identifiers lng_data is asked for them, above it is read once: either way every hit is found. */
+ public function testTheThresholdOf1000IdentifiersSwitchesFromTheListToOneFullRead(): void
+ {
+ $lng_data = [['module' => 'other', 'identifier' => 'id_5'], ['module' => 'other', 'identifier' => 'id_999']];
+ $entries = [];
+ for ($i = 0; $i < 1000; $i++) {
+ $entries['m#:#id_' . $i] = ['module' => 'm', 'identifier' => 'id_' . $i];
+ }
+
+ $at_threshold = $this->uniqueness($lng_data)->findDuplicatesAmong($entries);
+
+ $this->assertCount(1, $this->queries);
+ $this->assertStringContainsString(' WHERE ', $this->queries[0]);
+ $this->assertSame(['m#:#id_5' => ['other: id_5'], 'm#:#id_999' => ['other: id_999']], $at_threshold);
+
+ $entries['m#:#id_1000'] = ['module' => 'm', 'identifier' => 'id_1000'];
+ $lng_data[] = ['module' => 'other', 'identifier' => 'ID_1000'];
+
+ $above_threshold = $this->uniqueness($lng_data)->findDuplicatesAmong($entries);
+
+ $this->assertCount(2, $this->queries, 'one more query');
+ $this->assertStringNotContainsString(' WHERE ', $this->queries[1]);
+ $this->assertSame(
+ ['m#:#id_5' => ['other: id_5'], 'm#:#id_999' => ['other: id_999'], 'm#:#id_1000' => ['other: ID_1000']],
+ $above_threshold
+ );
+ }
+
+ /** A full read hands out rows of identifiers nobody asked for: they are no duplicates. */
+ public function testRowsOfOtherIdentifiersAreIgnoredByTheFullRead(): void
+ {
+ $entries = [];
+ for ($i = 0; $i < 1001; $i++) {
+ $entries[$i] = ['module' => 'm', 'identifier' => 'id_' . $i];
+ }
+
+ $this->assertSame([], $this->uniqueness([['module' => 'other', 'identifier' => 'unrelated']])->findDuplicatesAmong($entries));
+ }
+
+ public function testAtMostTenDuplicatesAreNamedPerEntryInSortedOrder(): void
+ {
+ $lng_data = [];
+ for ($i = 20; $i >= 1; $i--) {
+ $lng_data[] = ['module' => sprintf('mod%02d', $i), 'identifier' => 'x'];
+ }
+
+ $duplicates = $this->uniqueness($lng_data)->findDuplicatesAmong([0 => ['module' => 'common', 'identifier' => 'x']]);
+
+ $this->assertSame(
+ array_map(static fn(int $i): string => sprintf('mod%02d: x', $i), range(1, 10)),
+ $duplicates[0] ?? null
+ );
+ }
+
+ /** Many entries with one identifier must not make the comparison quadratic (every entry names at most ten). */
+ public function testManyEntriesWithTheSameIdentifierAreComparedInLinearTime(): void
+ {
+ $entries = [];
+ for ($i = 0; $i < 20000; $i++) {
+ $entries[$i] = ['module' => 'module_' . $i, 'identifier' => 'x'];
+ }
+
+ $start = microtime(true);
+ $duplicates = $this->uniqueness()->findDuplicatesAmong($entries);
+ $seconds = microtime(true) - $start;
+
+ $this->assertCount(20000, $duplicates);
+ $this->assertSame(10, count($duplicates[19999]));
+ $this->assertLessThan(5.0, $seconds);
+ }
+
+ public function testKnownEntriesCountLikeRowsOfTheDatabase(): void
+ {
+ $known = [
+ ['module' => 'mme', 'identifier' => 'login'],
+ ['module' => 'common', 'identifier' => 'own'],
+ ['module' => 'mme', 'identifier' => 'unrelated'],
+ ];
+
+ $this->assertSame(
+ ['common#:#Login' => ['mme: login']],
+ $this->uniqueness()->findDuplicatesAmong([
+ 'common#:#Login' => ['module' => 'common', 'identifier' => 'Login'],
+ 'common#:#own' => ['module' => 'common', 'identifier' => 'own'],
+ ], $known)
+ );
+ }
+
+ public function testFindDuplicatesOfAndFilterAddableDelegateTheSameRule(): void
+ {
+ $uniqueness = $this->uniqueness([['module' => 'mme', 'identifier' => 'login'], ['module' => 'common', 'identifier' => 'yes']]);
+
+ $this->assertSame(['mme: login'], $uniqueness->findDuplicatesOf('common', 'Login'));
+ $this->assertSame([], $uniqueness->findDuplicatesOf('common', 'yes'));
+ $this->assertSame(['yes', 'free'], $uniqueness->filterAddable(['common'], ['login', 'yes', 'free']));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/InvalidMarkupMessageTest.php b/components/ILIAS/Language/tests/ComponentTranslation/InvalidMarkupMessageTest.php
new file mode 100644
index 000000000000..f0915fa46019
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/InvalidMarkupMessageTest.php
@@ -0,0 +1,79 @@
+createStub(Language::class);
+ $lng->method('txt')->willReturn($text);
+
+ return $lng;
+ }
+
+ private function refinery(): Refinery
+ {
+ return new Refinery($this->createStub(DataFactory::class), $this->createStub(Language::class));
+ }
+
+ public function testListsTheEntriesWithTheirMarkupEscapedAndSeparatedBySemicolons(): void
+ {
+ $message = InvalidMarkupMessage::of($this->lng('Not allowed: %s'), $this->refinery(), [
+ 'tos#:#a' => ['tag Hallo']);
+
+ $this->sync(['greeting' => 'Hallo']);
+
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ }
+
+ /**
+ * Extracted comments ("#. ...", context for translators) are part of the shipped file and are
+ * carried into the overlay on creation/refresh.
+ */
+ public function testTheOverlayTakesTheExtractedCommentsOfTheShippedEntry(): void
+ {
+ $catalog = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo']);
+ $catalog->find(self::MODULE, 'greeting')->addExtractedComment('shown on the login page');
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame(['shown on the login page'], $this->overlayPo()->find(null, 'greeting')->getExtractedComments());
+ }
+
+ public function testTheOverlayTakesTheHeadersOfTheShippedPo(): void
+ {
+ $catalog = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo']);
+ $catalog->setHeader('Language', 'de');
+ $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ // a local change - with only shipped values there is no overlay (delta)
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame('de', $this->overlayPo()->getHeader('Language'));
+ $this->assertSame('nplurals=2; plural=(n != 1);', $this->overlayPo()->getHeader('Plural-Forms'));
+ }
+
+ /**
+ * refresh = false (an ordinary admin edit): the baseline "original" must never move - even when
+ * the shipped file changed in the meantime.
+ */
+ public function testAnAdminEditNeverMovesTheOriginal(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo, neu']);
+ $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $greeting = $this->overlayPo()->find(null, 'greeting');
+ $this->assertSame('Servus', $greeting->getTranslation());
+ $this->assertSame('Hallo', LocalChangeComments::getOriginal($greeting));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($greeting));
+ }
+
+ public function testAnAdminEditRemovesFuzzyOnlyFromTheValuesItActuallyChanges(): void
+ {
+ $this->seedShipped([
+ 'kept' => ['value' => 'Hello', 'fuzzy' => true],
+ 'edited' => ['value' => 'Bye', 'fuzzy' => true],
+ ]);
+ $this->seedOverlay([
+ 'kept' => ['value' => 'Hello', 'original' => 'Hello', 'fuzzy' => true],
+ 'edited' => ['value' => 'Bye', 'original' => 'Bye', 'fuzzy' => true],
+ ]);
+
+ $this->sync(['kept' => 'Hello', 'edited' => 'Tschüss']);
+
+ // the kept shipped value (fuzzy in the shipped .po) leaves the overlay, the edited one is no longer fuzzy
+ $overlay = $this->overlayPo();
+ $this->assertNull($overlay->find(null, 'kept'));
+ $this->assertFalse($overlay->find(null, 'edited')->hasFlag('fuzzy'));
+ }
+
+ public function testWritingTheOriginalValueBackClearsTheLocalChange(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['greeting' => 'Hallo']);
+
+ // the shipped value again is no local change: the entry, and with it the now empty overlay, is removed
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $this->assertFalse(MigratedLanguageFileSync::loadModuleTranslations(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir
+ )['greeting']['local_change']);
+ }
+
+ /**
+ * A full-replace write re-sends every value; an entry whose value did not change must keep its
+ * existing local_change timestamp (seeded with a fixed, old value - no sleep()/clock needed).
+ */
+ public function testTheLocalChangeTimestampOfAnUntouchedEntrySurvivesAnUnrelatedWrite(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'],
+ 'farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss'],
+ ]);
+
+ $this->sync(['greeting' => 'Servus', 'farewell' => 'Pfiat di']);
+
+ $overlay = $this->overlayPo();
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($overlay->find(null, 'greeting')));
+ $farewell_change = LocalChangeComments::getLocalChange($overlay->find(null, 'farewell'));
+ $this->assertNotNull($farewell_change);
+ $this->assertNotSame('2020-01-01T00:00:00Z', $farewell_change);
+ }
+
+ /**
+ * An overlay of an earlier version carried the module as msgctxt on every entry (see seedOverlay()); the next write
+ * must migrate it to the msgctxt-less shape (TranslationEntry::withContext(null)) while preserving translation,
+ * comments, flags, "local_change" and "original".
+ */
+ #[DataProvider('refreshFlags')]
+ public function testALegacyEntryWithTheModuleAsMsgctxtIsMigratedToNoContextPreservingItsBookkeeping(bool $refresh): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $legacy_overlay = MigratedPoFixture::catalog(self::MODULE, [
+ 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+ $legacy_overlay->find(self::MODULE, 'greeting')->addExtractedComment('shown on the login page');
+ MigratedPoFixture::writePair($this->overlayBase(), $legacy_overlay);
+ $this->assertNotNull($this->overlayPo()->find(self::MODULE, 'greeting'), 'precondition: seeded with the module as msgctxt');
+
+ // a genuine local change is kept alive by re-sending the exact same value
+ $this->sync(['greeting' => 'Servus'], $refresh);
+
+ $this->assertNull($this->overlayPo()->find(self::MODULE, 'greeting'), 'no longer stored under the module context');
+ $migrated = $this->overlayPo()->find(null, 'greeting');
+ $this->assertNotNull($migrated);
+ $this->assertSame('Servus', $migrated->getTranslation());
+ $this->assertSame('Hallo', LocalChangeComments::getOriginal($migrated));
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($migrated));
+ if (!$refresh) {
+ // refresh=true re-baselines extracted comments from the shipped entry (which has none
+ // here) - only an ordinary admin edit (refresh=false) keeps the overlay's own comment
+ $this->assertSame(['shown on the login page'], $migrated->getExtractedComments());
+ }
+ }
+
+ /**
+ * An overlay written by the pre-delta design carried EVERY entry of the module. The next reconciling write (Setup
+ * update) must shrink it to the true delta in one sync(): entries equal to the shipped value without local_change
+ * are dropped, a genuine local change survives - no dedicated migration step.
+ */
+ public function testALegacyFullOverlayShrinksToTheDeltaOnTheNextReconcilingWrite(): void
+ {
+ $this->seedShipped(['unchanged_one' => 'A', 'unchanged_two' => 'B', 'changed' => 'C']);
+ // A full overlay as an old version would have written it: every entry present, most of them
+ // carrying exactly the shipped value with no local_change at all.
+ $this->seedOverlay([
+ 'unchanged_one' => ['value' => 'A', 'original' => 'A'],
+ 'unchanged_two' => ['value' => 'B', 'original' => 'B'],
+ 'changed' => ['value' => 'C, lokal', 'original' => 'C', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['unchanged_one' => 'A', 'unchanged_two' => 'B', 'changed' => 'C, lokal'], true);
+
+ $this->assertSame(['changed'], $this->overlayIdentifiers());
+ $this->assertSame(
+ '2020-01-01T00:00:00Z',
+ LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'changed'))
+ );
+ }
+
+ public function testRefreshMovesTheOriginalToTheNewShippedValueAndClearsTheLocalChangeOfAnUnmodifiedEntry(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo, überarbeitet']);
+ $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
+
+ $this->sync(['greeting' => 'Hallo, überarbeitet'], true);
+
+ // the new shipped value is served from the shipped .po, the outdated overlay entry is removed
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertSame(
+ ['value' => 'Hallo, überarbeitet', 'local_change' => false, 'local_change_date' => null, 'original' => 'Hallo, überarbeitet'],
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['greeting']
+ );
+ }
+
+ public function testRefreshMovesTheOriginalButKeepsALocalCustomizationFlagged(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo, überarbeitet']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['greeting' => 'Servus'], true);
+
+ $greeting = $this->overlayPo()->find(null, 'greeting');
+ $this->assertSame('Servus', $greeting->getTranslation());
+ $this->assertSame('Hallo, überarbeitet', LocalChangeComments::getOriginal($greeting));
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($greeting));
+ }
+
+ /**
+ * An entry the shipped `.po` no longer contains (kept by the caller as a real local change, see
+ * LanguageInstallationManager::resolveMigratedModule()) keeps its last "original" and its
+ * local_change date - so a later update can still tell it from an unchanged entry.
+ */
+ public function testRefreshKeepsTheOriginalOfAnEntryTheShippedPoNoLongerContains(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'],
+ 'dropped' => ['value' => 'Neu', 'original' => 'Alt', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['greeting' => 'Hallo', 'dropped' => 'Neu'], true);
+
+ $dropped = $this->overlayPo()->find(null, 'dropped');
+ $this->assertSame('Alt', LocalChangeComments::getOriginal($dropped));
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($dropped));
+ }
+
+ /**
+ * On refresh fuzzy is recomputed from the shipped file: set exactly when the value is the shipped
+ * value and the shipped entry is fuzzy.
+ *
+ * @param array $shipped
+ * @param array $overlay
+ */
+ #[DataProvider('refreshFuzzyCases')]
+ public function testRefreshRecomputesFuzzyFromTheShippedEntry(
+ array $shipped,
+ array $overlay,
+ string $value,
+ ?bool $expected_fuzzy
+ ): void {
+ $this->seedShipped(['greeting' => $shipped]);
+ $this->seedOverlay(['greeting' => $overlay]);
+
+ $this->sync(['greeting' => $value], true);
+
+ // `null`: the value is the shipped one, so the entry (and its fuzzy flag) lives in the shipped .po only and the
+ // overlay is removed
+ if ($expected_fuzzy === null) {
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ return;
+ }
+ $this->assertSame($expected_fuzzy, $this->overlayPo()->find(null, 'greeting')->hasFlag('fuzzy'));
+ }
+
+ /**
+ * @return array>
+ */
+ public static function refreshFuzzyCases(): array
+ {
+ return [
+ 'shipped fuzzy, value is shipped' => [
+ ['value' => 'Hello', 'fuzzy' => true], ['value' => 'Hello', 'original' => 'Hello'], 'Hello', null,
+ ],
+ 'shipped fuzzy, value differs' => [
+ ['value' => 'Hello', 'fuzzy' => true], ['value' => 'Hallo', 'original' => 'Hello', 'fuzzy' => true], 'Hallo', false,
+ ],
+ 'shipped reviewed now, overlay still fuzzy' => [
+ ['value' => 'Hallo'], ['value' => 'Hallo', 'original' => 'Hello', 'fuzzy' => true], 'Hallo', null,
+ ],
+ ];
+ }
+
+ public function testReplaceSemanticsRemoveEveryEntryThatIsNotPassed(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ // local changes, since shipped values are never in the overlay
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'],
+ 'farewell' => ['value' => 'Pfiat di', 'original' => 'Tschüss', 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame(['greeting'], $this->overlayIdentifiers());
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /** An empty delta removes the overlay files - the shipped file and the lock file are kept. */
+ public function testSyncingNoEntriesRemovesTheOverlayButKeepsTheShippedFile(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]);
+
+ $this->sync([]);
+
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $this->assertFileExists(MigratedPoFixture::overlayLock($this->overlayBase()), 'the lock file is only removed on uninstall');
+ $this->assertNotNull(MigratedPoFixture::readPo($this->shippedPo())->find(self::MODULE, 'greeting'));
+ }
+
+ /**
+ * Entries of a different context (another module) are never taken over into this module's
+ * overlay: the overlay holds exactly $entries.
+ */
+ public function testEntriesOfAnotherContextInTheOldOverlayAreNotCarriedOver(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'],
+ 'foreign' => ['value' => 'Fremd', 'context' => 'other_module'],
+ ]);
+
+ // a local change keeps an overlay to look into (delta)
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertNull($this->overlayPo()->find('other_module', 'foreign'));
+ }
+
+ /**
+ * Sorted byte-wise by identifier, independent of the order of $entries - keeps the file
+ * diff-stable. SORT_STRING: upper case before lower case, "10" before "9".
+ */
+ public function testEntriesAreWrittenSortedByIdentifier(): void
+ {
+ $this->seedShipped(['b' => 'B']);
+
+ $this->sync(['b' => 'B', 'a' => 'A', 'C' => 'C', '9' => 'neun', '10' => 'zehn']);
+
+ // "b" carries the shipped value and is not written
+ $this->assertSame(['10', '9', 'C', 'a'], $this->overlayIdentifiers());
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEditableTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEditableTest.php
new file mode 100644
index 000000000000..cd74b4e679f0
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEditableTest.php
@@ -0,0 +1,121 @@
+|null identifier => value
+ */
+ private function editable(?string $client_data_dir = 'default', string $module = self::MODULE, string $lang_key = 'de', bool $for_writing = false): ?array
+ {
+ $content = MigratedLanguageFileSync::loadEditableModuleTranslations(
+ $this->manager,
+ $lang_key,
+ $module,
+ $client_data_dir === 'default' ? $this->client_data_dir : $client_data_dir,
+ $this->ilias_root,
+ $for_writing
+ );
+
+ return $content === null ? null : array_map(static fn(array $entry): string => $entry['value'], $content);
+ }
+
+ public function testTheShippedPoAndItsReadableOverlayAreMerged(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->seedOverlay(['greeting' => 'Moin']);
+
+ $this->assertSame(['greeting' => 'Moin', 'farewell' => 'Tschüss'], $this->editable());
+ }
+
+ public function testAnUnreadableOverlayYieldsTheShippedPoAloneAndIsReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Moin']);
+ file_put_contents($this->overlayBase() . '.po', "msgid \"kaputt\n");
+
+ $this->expectOutputRegex('/The local changes of module "stest" for "de" cannot be read, the shipped values are used/');
+ $this->assertSame(['greeting' => 'Hallo'], $this->editable());
+ }
+
+ /** For writing, the shipped values alone would drop the other local changes of the overlay: it throws instead. */
+ public function testAnUnreadableOverlayThrowsForWritingAndIsNotReportedAsUsable(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Moin']);
+ file_put_contents($this->overlayBase() . '.po', "msgid \"kaputt\n");
+
+ $this->expectException(\RuntimeException::class);
+ $this->editable('default', self::MODULE, 'de', true);
+ }
+
+ public function testForWritingAReadableOverlayIsMergedAsForDisplay(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->seedOverlay(['greeting' => 'Moin']);
+
+ $this->assertSame(['greeting' => 'Moin', 'farewell' => 'Tschüss'], $this->editable('default', self::MODULE, 'de', true));
+ }
+
+ public function testWithoutClientDataDirectoryTheShippedPoAloneIsReturned(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertSame(['greeting' => 'Hallo'], $this->editable(null));
+ $this->expectOutputString('');
+ }
+
+ public function testAnUnreadableShippedPoThrows(): void
+ {
+ file_put_contents($this->shippedPo(), "msgid \"kaputt\n");
+
+ $this->expectOutputRegex('/cannot be read/');
+ $this->expectException(\RuntimeException::class);
+ $this->editable();
+ }
+
+ /**
+ * @return array
+ */
+ public static function nothingToEdit(): array
+ {
+ return [
+ 'module of no directory' => ['unknown', 'de'],
+ 'no shipped po for the language' => ['stest', 'fr'],
+ 'invalid language key' => ['stest', '../de'],
+ ];
+ }
+
+ #[DataProvider('nothingToEdit')]
+ public function testWithoutDirectoryShippedPoOrValidKeyThereIsNothingToEdit(string $module, string $lang_key): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertNull($this->editable('default', $module, $lang_key));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEntryRulesTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEntryRulesTest.php
new file mode 100644
index 000000000000..590abea356bf
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEntryRulesTest.php
@@ -0,0 +1,328 @@
+assertSame($expected, MigratedLanguageFileSync::belongsInDelta($shipped_value, $value));
+ }
+
+ /**
+ * @return array
+ */
+ public static function belongsInDeltaCases(): array
+ {
+ return [
+ 'differs from shipped' => ['Hallo', 'Servus', true],
+ 'same as shipped' => ['Hallo', 'Hallo', false],
+ 'key not shipped at all - any non-empty value belongs' => [null, 'Eigener Wert', true],
+ 'empty value resets to shipped, even without a shipped key' => [null, '', false],
+ 'empty value resets to shipped, even when shipped differs' => ['Hallo', '', false],
+ 'shipped value itself is empty, value matches' => ['', '', false],
+ 'shipped value itself is empty, value differs' => ['', 'x', true],
+ 'the literal string "0" belongs when shipped differs' => ['1', '0', true],
+ 'the literal string "0" is not "empty" and does not reset' => [null, '0', true],
+ ];
+ }
+
+ /**
+ * Shared by belongsInDelta() and ilObjLanguageExt::_saveValues(): an empty value resets to the shipped one, but
+ * only for a key the shipped `.po` carries.
+ */
+ #[DataProvider('resolveLocalValueCases')]
+ public function testResolveLocalValue(?string $shipped_value, string $value, string $expected): void
+ {
+ $this->assertSame($expected, MigratedLanguageFileSync::resolveLocalValue($shipped_value, $value));
+ }
+
+ /**
+ * @return array
+ */
+ public static function resolveLocalValueCases(): array
+ {
+ return [
+ 'empty value of a shipped key resolves to the shipped value' => ['Hallo', '', 'Hallo'],
+ 'empty value of a key that is not shipped stays empty' => [null, '', ''],
+ 'a non-empty value is returned unchanged, shipped key' => ['Hallo', 'Servus', 'Servus'],
+ 'a non-empty value is returned unchanged, not a shipped key' => [null, 'Eigener Wert', 'Eigener Wert'],
+ 'the shipped value itself is empty - stays empty (nothing to reset to in practice)' => ['', '', ''],
+ ];
+ }
+
+ /**
+ * databaseValues() sanitises only an unmodified value equal to the shipped one (for the database mirror); a locally
+ * changed value stays untouched, its markup is checked by the caller.
+ */
+ public function testDatabaseValuesSanitizesOnlyAnUnmodifiedValueEqualToTheShippedOne(): void
+ {
+ $bad = 'Hallo';
+
+ $result = MigratedLanguageFileSync::databaseValues(
+ ['unmodified' => $bad, 'changed' => $bad, 'not_shipped_at_all' => $bad],
+ ['unmodified' => $bad, 'changed' => 'Anderer Shipped-Wert']
+ );
+
+ $this->assertStringNotContainsString('Hallo';
+ $this->seedShipped(['greeting' => $bad]);
+
+ $result = MigratedLanguageFileSync::databaseValuesOf(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ self::MODULE,
+ ['greeting' => $bad]
+ );
+
+ $this->assertStringNotContainsString('Hallo';
+
+ $result = MigratedLanguageFileSync::databaseValuesOf(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ 'not_a_contributed_module',
+ ['greeting' => $bad]
+ );
+
+ $this->assertSame($bad, $result['greeting']);
+ }
+
+ /**
+ * An unreadable shipped `.po` must not abort the database write: $entries come back unchanged (the overlay write
+ * reports the file).
+ */
+ public function testDatabaseValuesOfLeavesEntriesUnchangedWhenTheShippedPoCannotBeRead(): void
+ {
+ file_put_contents($this->shippedPo(), "msgid \"kaputt\n");
+ $bad = 'Hallo';
+
+ $result = MigratedLanguageFileSync::databaseValuesOf(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ self::MODULE,
+ ['greeting' => $bad]
+ );
+
+ $this->assertSame($bad, $result['greeting']);
+ }
+
+ public function testHasOverlayIsTrueWhenEitherOverlayFileExists(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Servus']);
+
+ $this->assertTrue(MigratedLanguageFileSync::hasOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ public function testHasOverlayIsFalseWithoutAnyOverlayFile(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertFalse(MigratedLanguageFileSync::hasOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ public function testHasOverlayIsFalseForAnUnknownModuleOrWithoutClientDataDir(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Servus']);
+
+ $this->assertFalse(MigratedLanguageFileSync::hasOverlay($this->manager, 'de', 'other_module', $this->client_data_dir));
+ $this->assertFalse(MigratedLanguageFileSync::hasOverlay($this->manager, 'de', self::MODULE, null));
+ }
+
+ /**
+ * hasOverlay() reports a non-regular file (e.g. a directory) as present, unlike is_file(); reading it is covered by
+ * assertReadableOverlay().
+ */
+ public function testHasOverlayIsTrueEvenForAnIrregularFileAtTheOverlayPosition(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ mkdir($this->overlayBase() . '.po', 0775, true);
+
+ $this->assertTrue(MigratedLanguageFileSync::hasOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ /** An entry without msgctxt (`null`) belongs to whichever module reads the catalog. */
+ public function testModuleEntriesIncludesAnEntryWithoutAnyContext(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry(null, 'greeting', 'Hallo'));
+
+ $entries = MigratedLanguageFileSync::moduleEntries($catalog, 'itest');
+
+ $this->assertArrayHasKey('greeting', $entries);
+ $this->assertSame('Hallo', $entries['greeting']->getTranslation());
+ }
+
+ /** An entry with an empty msgctxt is a foreign context, not "no context". */
+ #[DataProvider('foreignContexts')]
+ public function testModuleEntriesIgnoresAnEntryOfAForeignContext(string $context): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry($context, 'greeting', 'Fremd'));
+
+ $entries = MigratedLanguageFileSync::moduleEntries($catalog, 'itest');
+
+ $this->assertArrayNotHasKey('greeting', $entries);
+ }
+
+ /**
+ * @return array
+ */
+ public static function foreignContexts(): array
+ {
+ return ['empty-string msgctxt' => [''], 'context of another module' => ['other_module']];
+ }
+
+ /** A key with and without context: the module-context entry wins regardless of catalog order. */
+ #[DataProvider('contextOrders')]
+ public function testModuleEntriesPrefersTheModuleContextEntryOverTheContextlessOne(bool $module_first): void
+ {
+ $catalog = new TranslationCatalog();
+ $module_entry = MigratedPoFixture::entry('itest', 'greeting', 'Mit Modulkontext');
+ $contextless = MigratedPoFixture::entry(null, 'greeting', 'Ohne Kontext');
+ foreach ($module_first ? [$module_entry, $contextless] : [$contextless, $module_entry] as $entry) {
+ $catalog->add($entry);
+ }
+
+ $entries = MigratedLanguageFileSync::moduleEntries($catalog, 'itest');
+
+ $this->assertSame('Mit Modulkontext', $entries['greeting']->getTranslation());
+ }
+
+ /**
+ * @return array
+ */
+ public static function contextOrders(): array
+ {
+ return ['module context first' => [true], 'contextless first' => [false]];
+ }
+
+
+ public function testFindModuleEntryFallsBackToTheContextlessEntry(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry(null, 'greeting', 'Hallo'));
+
+ $entry = MigratedLanguageFileSync::findModuleEntry($catalog, 'itest', 'greeting');
+
+ $this->assertNotNull($entry);
+ $this->assertSame('Hallo', $entry->getTranslation());
+ }
+
+ public function testFindModuleEntryPrefersTheModuleContextEntry(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry(null, 'greeting', 'Ohne Kontext'));
+ $catalog->add(MigratedPoFixture::entry('itest', 'greeting', 'Mit Modulkontext'));
+
+ $entry = MigratedLanguageFileSync::findModuleEntry($catalog, 'itest', 'greeting');
+
+ $this->assertSame('Mit Modulkontext', $entry?->getTranslation());
+ }
+
+ public function testFindModuleEntryReturnsNullForAForeignContext(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry('other_module', 'greeting', 'Fremd'));
+
+ $this->assertNull(MigratedLanguageFileSync::findModuleEntry($catalog, 'itest', 'greeting'));
+ }
+
+ /**
+ * BUG (documented, not fixed): unlike moduleEntries(), findModuleEntry()'s fallback `$catalog->find(null,
+ * $identifier)` also matches an entry with msgctxt "" - gettext/gettext builds the key as
+ * "{$context}\004{$original}", so null and '' collide.
+ */
+ public function testFindModuleEntryIgnoresAnEmptyStringContextInsteadOfTreatingItAsContextless(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry('', 'farewell', 'Leer'));
+
+ $this->assertNull(MigratedLanguageFileSync::findModuleEntry($catalog, 'itest', 'farewell'));
+ }
+
+ /** End-to-end: a contextless shipped entry is served for the module via loadShippedModuleEntries(). */
+ public function testLoadShippedModuleEntriesIncludesAContextlessEntry(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry(null, 'greeting', 'Hallo'));
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $entries = MigratedLanguageFileSync::loadShippedModuleEntries($this->shippedPo(), self::MODULE);
+
+ $this->assertSame('Hallo', $entries['greeting']['value']);
+ }
+
+ /**
+ * End-to-end: sync() reads a contextless shipped entry as the module's shipped value, so resyncing it creates no
+ * overlay.
+ */
+ public function testSyncTreatsAContextlessShippedEntryAsThisModulesShippedValue(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->add(MigratedPoFixture::entry(null, 'greeting', 'Hallo'));
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $this->sync(['greeting' => 'Hallo']);
+
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'unchanged from the contextless shipped value - no delta at all');
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncFindModulesWithOverlayTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncFindModulesWithOverlayTest.php
new file mode 100644
index 000000000000..c4018e2401af
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncFindModulesWithOverlayTest.php
@@ -0,0 +1,126 @@
+//` counts, nothing else below `lang`. */
+class MigratedLanguageFileSyncFindModulesWithOverlayTest extends MigratedLanguageFileSyncTestCase
+{
+ private function touch(string $relative): void
+ {
+ $file = $this->client_data_dir . '/lang/' . $relative;
+ if (!is_dir(dirname($file))) {
+ mkdir(dirname($file), 0775, true);
+ }
+ file_put_contents($file, 'x');
+ }
+
+ /**
+ * @return array file below `lang/zmod/de/`, whether it makes an overlay
+ */
+ public static function overlayMarkers(): array
+ {
+ return [
+ 'current' => ['current', true],
+ 'po' => ['zmod_de.po', true],
+ 'only the lock' => ['zmod_de.lock', false],
+ 'only the mo' => ['zmod_de.mo', false],
+ ];
+ }
+
+ #[DataProvider('overlayMarkers')]
+ public function testOnlyCurrentOrAPoMakesAnOverlay(string $file, bool $is_overlay): void
+ {
+ $this->touch('zmod/de/' . $file);
+
+ $this->assertSame($is_overlay ? ['zmod'] : [], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+ }
+
+ public function testAModuleIsFoundPerLanguageAndTheResultIsSorted(): void
+ {
+ $this->touch('zmod/de/current');
+ $this->touch('amod/de/amod_de.po');
+ $this->touch('fmod/fr/current');
+
+ $this->assertSame(['amod', 'zmod'], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+ $this->assertSame(['fmod'], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'fr'));
+ }
+
+ /** The lock directory and the record sit next to the module directories and are no modules. */
+ public function testLocksAndTheTakenOverRecordAreNoModules(): void
+ {
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['zmod']);
+ $this->touch('.locks/de/current');
+ $this->touch('.locks/de/x.po');
+
+ $this->assertFileExists(MigratedLanguageFilePaths::takenOverFile($this->client_data_dir, 'de'));
+ $this->assertSame([], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+ }
+
+ public function testSymbolicLinksAreNotFollowed(): void
+ {
+ $outside = sys_get_temp_dir() . '/ilias_find_overlay_outside_' . bin2hex(random_bytes(4));
+ mkdir($outside . '/de', 0775, true);
+ file_put_contents($outside . '/de/current', 'x');
+ try {
+ mkdir($this->client_data_dir . '/lang/linkedmod', 0775, true);
+ symlink($outside . '/de', $this->client_data_dir . '/lang/linkedmod/de');
+ symlink($outside, $this->client_data_dir . '/lang/linkedmodule');
+ mkdir($this->client_data_dir . '/lang/linkedcurrent/de', 0775, true);
+ symlink($outside . '/missing', $this->client_data_dir . '/lang/linkedcurrent/de/current');
+
+ $this->assertSame(['linkedcurrent'], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+ } finally {
+ MigratedPoFixture::removeDirectory($outside);
+ }
+ }
+
+ public function testNoClientDataDirectoryAnInvalidLanguageKeyOrNoLangDirectoryFindNothing(): void
+ {
+ $this->touch('zmod/de/current');
+
+ $this->assertSame([], MigratedLanguageFileSync::findModulesWithOverlay(null, 'de'));
+ $this->assertSame([], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, '../de'));
+ $this->assertSame([], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir . '/missing', 'de'));
+ }
+
+ /** An unreadable module directory counts as overlay, an unreadable overlay root finds nothing. */
+ public function testUnreadableDirectoriesCountAsOverlayOrFindNothing(): void
+ {
+ $this->touch('amod/de/current');
+ mkdir($this->client_data_dir . '/lang/zmod/de', 0775, true);
+ chmod($this->client_data_dir . '/lang/zmod/de', 0000);
+ try {
+ if (is_readable($this->client_data_dir . '/lang/zmod/de')) {
+ $this->markTestSkipped('permissions are not enforced (root)');
+ }
+ $this->assertSame(['amod', 'zmod'], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+
+ chmod($this->client_data_dir . '/lang', 0000);
+ $this->assertSame([], MigratedLanguageFileSync::findModulesWithOverlay($this->client_data_dir, 'de'));
+ } finally {
+ chmod($this->client_data_dir . '/lang', 0775);
+ chmod($this->client_data_dir . '/lang/zmod/de', 0775);
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncGuardsTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncGuardsTest.php
new file mode 100644
index 000000000000..b47d763ef83f
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncGuardsTest.php
@@ -0,0 +1,356 @@
+seedShipped(['greeting' => 'Hallo']);
+
+ MigratedLanguageFileSync::sync($this->manager, $this->ilias_root, 'de', 'other_module', ['x' => 'y'], $this->client_data_dir);
+ MigratedLanguageFileSync::sync($this->manager, $this->ilias_root, 'de', '', ['x' => 'y'], $this->client_data_dir, true);
+
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ }
+
+ public function testIsANoOpWhenNoShippedPoExistsForTheLanguage(): void
+ {
+ $this->seedShipped(['greeting' => 'Hello'], 'en');
+
+ $this->sync(['greeting' => 'Hallo'], true);
+
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ }
+
+ /**
+ * Un-migration: the shipped `.po` of a migrated module was removed after its overlay was compiled - sync() leaves
+ * the overlay files untouched.
+ */
+ public function testAnExistingOverlayIsLeftUntouchedWhenTheShippedPoNoLongerExists(): void
+ {
+ $this->seedShipped(['greeting' => 'Hello']);
+ $this->seedOverlay(['greeting' => 'Hallo']);
+ $po_before = file_get_contents($this->overlayBase() . '.po');
+ $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
+ unlink($this->shippedPo());
+
+ $this->sync(['greeting' => 'Hallo']);
+
+ $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
+ $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * Readers must not serve a frozen overlay while its shipped `.po` is missing:
+ * getMigratedModules()/loadModuleTranslations() (and ilLanguage) fall back to lng_modules/lng_data as if no overlay
+ * existed.
+ */
+ public function testReadersIgnoreAnExistingOverlayWhileTheShippedPoIsMissing(): void
+ {
+ $this->seedShipped(['greeting' => 'Hello']);
+ $this->seedOverlay(['greeting' => 'Hallo']);
+ unlink($this->shippedPo());
+
+ $this->assertNull(MigratedLanguageFileSync::loadModuleTranslations(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ $this->ilias_root
+ ));
+ $this->assertSame(
+ [],
+ MigratedLanguageFileSync::getMigratedModules($this->manager, 'de', $this->client_data_dir, $this->ilias_root)
+ );
+ }
+
+ /**
+ * sync() writes exactly the entries it is given; the shipped-vs-local decision once the `.po` returns is
+ * LanguageInstallationManager::resolveMigratedModule()'s, based on the "original" the overlay preserved during the
+ * outage.
+ */
+ public function testARefreshAfterTheShippedPoComesBackStillMovesTheOriginalForwardCorrectly(): void
+ {
+ $this->seedShipped(['greeting' => 'Hello']);
+ // the "original" of this local change must survive the whole gap below untouched
+ $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hello', 'local_change' => '2020-01-01T00:00:00Z']]);
+ unlink($this->shippedPo());
+
+ // asserted here so a regression touching the overlay's "original"/local_change bookkeeping while the `.po` is
+ // missing fails here, not only once it is back ($refresh_original_from_shipped would then mask the loss)
+ $this->sync(['greeting' => 'Hallo']);
+ // untouched, so still as seeded: with the module as msgctxt
+ $preserved_during_the_gap = $this->overlayPo()->find(self::MODULE, 'greeting');
+ $this->assertSame('Hello', LocalChangeComments::getOriginal($preserved_during_the_gap), 'original survives the gap');
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($preserved_during_the_gap));
+
+ // the module is migrated again - the shipped `.po` is back, with a changed value
+ $this->seedShipped(['greeting' => 'Servus']);
+
+ // an unmodified local value ("Hallo" is still tracked against the preserved original
+ // "Hello") is refreshed to the new shipped value, exactly like an ordinary update
+ $this->sync(['greeting' => 'Servus'], true);
+
+ // the value is now the shipped one: served from the shipped .po, the overlay is removed
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertSame(
+ ['value' => 'Servus', 'local_change' => false, 'local_change_date' => null, 'original' => 'Servus'],
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['greeting']
+ );
+ }
+
+ public function testIsANoOpWhenTheClientDataDirIsNull(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $shipped_before = file_get_contents($this->shippedPo());
+
+ $this->sync(['greeting' => 'Servus'], true, null);
+
+ $this->assertSame($shipped_before, file_get_contents($this->shippedPo()));
+ $this->assertSame(['stest_de.po'], array_values(array_diff(scandir($this->fixture_directory), ['.', '..'])));
+ }
+
+ /**
+ * A corrupt overlay `.po` is rebuilt like a missing one (original/local_change recomputed against the shipped file)
+ * instead of blocking every further write.
+ */
+ #[DataProvider('refreshFlags')]
+ public function testACorruptOverlayPoIsRebuiltFromEntriesAndTheShippedPo(bool $refresh): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
+ file_put_contents($this->overlayBase() . '.po', "msgid \"broken\nmsgstr \"\"\n");
+
+ $this->sync(['greeting' => 'Hallo', 'farewell' => 'Servus'], $refresh);
+
+ // "greeting" carries the shipped value and is not written
+ $overlay = $this->overlayPo();
+ $this->assertSame(['farewell'], $this->overlayIdentifiers());
+ $farewell = $overlay->find(null, 'farewell');
+ $this->assertSame('Tschüss', LocalChangeComments::getOriginal($farewell));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($farewell));
+ $this->assertSame(['farewell' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * StrictPoLoader rejects more than the old parser (e.g. a duplicate or a message without msgstr): such an overlay
+ * is rebuilt, too.
+ */
+ #[DataProvider('overlaysOnlyAStrictParserRejects')]
+ public function testAnOverlayPoTheStrictParserRejectsIsRebuilt(string $content): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
+ file_put_contents($this->overlayBase() . '.po', $content);
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $greeting = $this->overlayPo()->find(null, 'greeting');
+ $this->assertSame('Servus', $greeting?->getTranslation());
+ $this->assertSame('Hallo', LocalChangeComments::getOriginal($greeting));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($greeting));
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * @return array>
+ */
+ public static function overlaysOnlyAStrictParserRejects(): array
+ {
+ $message = "msgctxt \"" . self::MODULE . "\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n";
+
+ return [
+ 'duplicate message' => [$message . "\n" . $message],
+ 'message without msgstr' => ["msgctxt \"" . self::MODULE . "\"\nmsgid \"greeting\"\n"],
+ 'truncated inside a string' => [substr($message, 0, -4)],
+ 'gap in the plural forms' => ["msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"x\"\nmsgstr[2] \"z\"\n"],
+ 'binary data' => ["\x00\x01\x02\xde\x12\x04\x95"],
+ 'header value with a line break' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\n" . $message],
+ ];
+ }
+
+ public function testACorruptShippedPoThrows(): void
+ {
+ file_put_contents($this->shippedPo(), "msgid \"broken\n");
+
+ $this->expectException(RuntimeException::class);
+ $this->sync(['greeting' => 'Hallo']);
+ }
+
+ /**
+ * A shipped `.po` whose header keeps a line break is broken (checkLanguage() refuses the language): sync() throws a
+ * RuntimeException before writing, not the InvalidArgumentException setHeader() would raise.
+ */
+ public function testAShippedPoWithALineBreakInAHeaderValueThrowsARuntimeExceptionAndWritesNothing(): void
+ {
+ file_put_contents(
+ $this->shippedPo(),
+ "msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\nmsgctxt \"" . self::MODULE . "\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n"
+ );
+
+ try {
+ $this->sync(['greeting' => 'Hallo']);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('contains a line break', $e->getMessage());
+ }
+
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()));
+ }
+
+ /**
+ * Root-proof write failure: a regular file where the overlay's "lang" directory must be created - mkdir() fails for
+ * every user.
+ */
+ public function testThrowsWhenTheOverlayDirectoryCannotBeCreated(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ file_put_contents($this->client_data_dir . '/lang', 'not a directory');
+
+ set_error_handler(static fn(): bool => true, E_WARNING);
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Could not create directory/');
+ $this->sync(['greeting' => 'Servus']);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * acquireLock() logs a warning when the lock file cannot be opened (e.g. missing permission), and the callback
+ * still runs unlocked. Only the lock file's permission is removed, so the overlay can still be written.
+ */
+ public function testAcquireLockLogsAWarningWhenTheLockFileCannotBeOpenedButTheWriteStillSucceeds(): void
+ {
+ if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
+ $this->markTestSkipped('An unopenable file cannot be forced for root.');
+ }
+ $this->seedShipped(['greeting' => 'Hallo']);
+ // a first sync creates the lock file (and the overlay) so its own permission can be revoked
+ $this->sync(['greeting' => 'Servus']);
+ $lock_file = MigratedPoFixture::overlayLock($this->overlayBase());
+ $this->assertFileExists($lock_file);
+ chmod($lock_file, 0000);
+
+ $this->expectErrorLog();
+ $this->sync(['greeting' => 'Nochmal geändert']);
+
+ $this->assertStringContainsString('Could not open the lock file', $this->errorLog());
+ $this->assertSame(['greeting' => 'Nochmal geändert'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * @return string what error_log() wrote so far - PHPUnit redirects it to a per-test capture file
+ * (see expectErrorLog()).
+ */
+ private function errorLog(): string
+ {
+ $file = (string) ini_get('error_log');
+ return $file !== '' && is_file($file) ? (string) file_get_contents($file) : '';
+ }
+
+ /**
+ * Root-proof write failure of the file itself: a directory at the overlay `.po` path, so the final rename() fails.
+ * The temporary file must not be left behind.
+ */
+ public function testThrowsWhenTheOverlayPoCannotBeReplacedAndLeavesNoTemporaryFile(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ mkdir($this->overlayBase() . '.po', 0775, true);
+
+ set_error_handler(static fn(): bool => true, E_WARNING);
+ try {
+ $this->sync(['greeting' => 'Servus']);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ // refused before any write is attempted: the .po path is no regular file
+ $this->assertStringContainsString('no regular file', $e->getMessage());
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame(
+ // the lock file (see MigratedLanguageFileSync::withOverlayLock()) is never removed
+ ['lock', 'stest_de.po'],
+ array_values(array_diff(scandir(dirname($this->overlayBase())), ['.', '..'])),
+ 'neither a temporary file nor a .mo may be left behind'
+ );
+ }
+
+ /**
+ * A symlinked client data directory (or parent), e.g. a mounted data volume: tempnam() resolves symlinks, which
+ * made AtomicFileWriter's "same directory" check reject every write (regression).
+ */
+ public function testWritesTheOverlayWhenTheClientDataDirIsASymlink(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $link = $this->client_data_dir . '-link';
+ symlink($this->client_data_dir, $link);
+
+ try {
+ MigratedLanguageFileSync::sync($this->manager, $this->ilias_root, 'de', self::MODULE, ['greeting' => 'Servus'], $link);
+ } finally {
+ unlink($link);
+ }
+
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * Race: an installation decides on $entries before a concurrent admin edit adds a local change and completes its
+ * own sync() - all before the installation's sync() reaches its lock.
+ */
+ public function testAnInstallationDoesNotDiscardAnOverlayCreatedConcurrentlyAfterItsDecision(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ // The concurrent admin edit completes first and creates a genuine local change.
+ $this->sync(['greeting' => 'Servus']);
+ $this->assertFileExists($this->overlayBase() . '.po', 'precondition: the admin edit created an overlay');
+
+ // The installation's snapshot is from BEFORE that edit: no overlay, only the shipped value.
+ try {
+ MigratedLanguageFileSync::sync($this->manager, $this->ilias_root, 'de', self::MODULE, ['greeting' => 'Hallo'], $this->client_data_dir, true, false);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('was created after', $e->getMessage());
+ }
+
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ $this->assertTrue(
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['greeting']['local_change']
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRemoveOverlayTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRemoveOverlayTest.php
new file mode 100644
index 000000000000..c7f151c63800
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRemoveOverlayTest.php
@@ -0,0 +1,171 @@
+client_data_dir, $this->directory, 'de');
+ mkdir(dirname($overlay_base), 0775, true);
+ file_put_contents(MigratedPoFixture::overlayLock($overlay_base), '');
+
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayLock($overlay_base));
+ }
+
+ /**
+ * A nested removeOverlay() under an already held lock removes the overlay but neither deletes nor
+ * replaces the lock file (same inode throughout).
+ */
+ public function testANestedRemoveOverlayInsideWithOverlayLockDoesNotDeleteTheLockFileOrRetry(): void
+ {
+ $overlay_base = MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, 'de');
+ mkdir(dirname($overlay_base), 0775, true);
+ file_put_contents($overlay_base . '.po', 'PO');
+ MigratedPoFixture::writeOverlayRevision($overlay_base, MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
+ file_put_contents(MigratedPoFixture::overlayLock($overlay_base), '');
+ clearstatcache(true, MigratedPoFixture::overlayLock($overlay_base));
+ $lock_inode_before = lstat(MigratedPoFixture::overlayLock($overlay_base))['ino'];
+
+ // Run in a child process with a hard timeout: without the re-entrancy guard this scenario is
+ // the very same same-process self-deadlock testANestedWithOverlayLockForTheSameModuleReturns...
+ // above guards against - the nested removeOverlay() would block forever on the very lock its
+ // own enclosing withOverlayLock() call already holds.
+ $result = $this->runPhpWithTimeout($this->nestedRemoveOverlayChildCode(), 5.0);
+
+ $this->assertTrue($result['finished'], 'a nested removeOverlay() must return instead of hanging: ' . $result['output']);
+ $this->assertSame(0, $result['exit_code'], $result['output']);
+
+ clearstatcache(true, $overlay_base . '.po');
+ clearstatcache(true, MigratedPoFixture::overlayMo($overlay_base));
+ clearstatcache(true, MigratedPoFixture::overlayLock($overlay_base));
+ $this->assertFileDoesNotExist($overlay_base . '.po', 'the nested removeOverlay() must still actually remove the overlay files');
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($overlay_base));
+ $this->assertStringContainsString(
+ 'AFTER_NESTED:' . $lock_inode_before . "\n",
+ $result['output'],
+ 'a removeOverlay() nested inside an already-held lock must NOT delete or replace the lock file - the outer caller still relies on it'
+ );
+ $this->assertFileExists(MigratedPoFixture::overlayLock($overlay_base));
+ $this->assertSame(
+ $lock_inode_before,
+ lstat(MigratedPoFixture::overlayLock($overlay_base))['ino'],
+ 'the lock file must stay the exact same one throughout - no retry/replace cycle for the nested call'
+ );
+ }
+
+ private function nestedRemoveOverlayChildCode(): string
+ {
+ $template = <<<'PHP'
+ use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+ use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync;
+
+ $root = __ROOT__;
+ require_once $root . '/vendor/composer/vendor/autoload.php';
+
+ $directory = new class implements LanguageFileDirectory {
+ public function getPrefix(): string
+ {
+ return 'stest';
+ }
+ public function getPath(): string
+ {
+ return 'x/';
+ }
+ public function getSuffix(): string
+ {
+ return '';
+ }
+ public function isLocal(): bool
+ {
+ return false;
+ }
+ };
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+ $client_data_dir = __CLIENT_DATA_DIR__;
+
+ MigratedLanguageFileSync::withOverlayLock(
+ $manager,
+ 'de',
+ 'stest',
+ $client_data_dir,
+ function () use ($manager, $client_data_dir): void {
+ MigratedLanguageFileSync::removeOverlay($manager, 'de', 'stest', $client_data_dir);
+ clearstatcache();
+ $lock = @lstat($client_data_dir . '/lang/stest/de/lock');
+ echo 'AFTER_NESTED:' . ($lock === false ? 'missing' : $lock['ino']) . "\n";
+ }
+ );
+ echo "done\n";
+ PHP;
+
+ return str_replace(
+ ['__ROOT__', '__CLIENT_DATA_DIR__'],
+ [var_export(ILIAS_ABSOLUTE_PATH, true), var_export($this->client_data_dir, true)],
+ $template
+ );
+ }
+
+ /**
+ * A sync after removeOverlay() (reinstall) creates a fresh .lock.
+ */
+ public function testASyncAfterRemoveOverlayCreatesANewLockFile(): void
+ {
+ $relative_path = 'components/ILIAS/Language/tests/ComponentTranslation/tmp-lock-shipped-' . bin2hex(random_bytes(4)) . '/';
+ MigratedPoFixture::writeShippedPo(
+ $relative_path,
+ self::MODULE,
+ 'de',
+ MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo'])
+ );
+ $directory = MigratedPoFixture::directory(self::MODULE, $relative_path);
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+ $overlay_base = MigratedPoFixture::overlayBase($this->client_data_dir, $directory, 'de');
+
+ try {
+ // a local change: a shipped value writes no overlay, and without an overlay no lock file is kept
+ MigratedLanguageFileSync::sync($manager, ILIAS_ABSOLUTE_PATH, 'de', self::MODULE, ['greeting' => 'Moin'], $this->client_data_dir);
+ $this->assertFileExists(MigratedPoFixture::overlayLock($overlay_base), 'sync() must leave a .lock file behind for later callers to serialize on');
+
+ MigratedLanguageFileSync::removeOverlay($manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayLock($overlay_base), 'removeOverlay() (uninstall) must have removed it');
+
+ MigratedLanguageFileSync::sync($manager, ILIAS_ABSOLUTE_PATH, 'de', self::MODULE, ['greeting' => 'Servus'], $this->client_data_dir);
+ $this->assertFileExists(MigratedPoFixture::overlayLock($overlay_base), 'a reinstall/resync must create a fresh .lock file');
+ } finally {
+ MigratedPoFixture::removeShippedDirectory($relative_path);
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRetryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRetryTest.php
new file mode 100644
index 000000000000..49bf8edf53d2
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRetryTest.php
@@ -0,0 +1,493 @@
+ $ignored_fds
+ */
+ private function waitForOpenFd(int $pid, string $path, float $timeout_seconds, array $ignored_fds = []): bool
+ {
+ $deadline = microtime(true) + $timeout_seconds;
+ do {
+ foreach ($this->openFdsFor($pid, $path) as $entry) {
+ if (!in_array($entry, $ignored_fds, true)) {
+ return true;
+ }
+ }
+ usleep(1_000);
+ } while (microtime(true) < $deadline);
+
+ return false;
+ }
+
+ /**
+ * Skips the test unless /proc/self/fd exists, which waitForOpenFd() needs; call before taking any lock
+ * or starting a child.
+ */
+ private function requireProcFdSupport(): void
+ {
+ if (!is_dir('/proc/self/fd')) {
+ $this->markTestSkipped('This test requires /proc (Linux) to observe the child process\'s open file descriptors.');
+ }
+ }
+
+ /**
+ * The descriptor numbers of $pid pointing at realpath($path) (resolved once, because /proc readlink()
+ * returns resolved targets).
+ *
+ * @return list the file descriptor numbers of $pid whose target is exactly the realpath()
+ * of $path - resolved once so a TMPDIR that is itself a symlink (readlink() on /proc//fd/*
+ * always returns the fully resolved target) does not make a genuine match go unrecognized.
+ */
+ private function openFdsFor(int $pid, string $path): array
+ {
+ $matches = [];
+ $entries = @scandir('/proc/' . $pid . '/fd');
+ if ($entries === false) {
+ return $matches;
+ }
+ $resolved_path = realpath($path) ?: $path;
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ if (@readlink('/proc/' . $pid . '/fd/' . $entry) === $resolved_path) {
+ $matches[] = $entry;
+ }
+ }
+
+ return $matches;
+ }
+
+ /**
+ * The $ignored_fds baseline for waitForOpenFd(): descriptors of $pid already pointing at $path, to be
+ * taken right after starting the child.
+ *
+ * @return list
+ */
+ private function seedIgnoredFds(int $pid, string $path): array
+ {
+ return $this->openFdsFor($pid, $path);
+ }
+
+ private function lockAttempts(): int
+ {
+ return (new \ReflectionClassConstant(MigratedLanguageFileSync::class, 'LOCK_ATTEMPTS'))->getValue();
+ }
+
+ /**
+ * One withOverlayLock() call in a child process, printing RESULT: or
+ * EXCEPTION:: plus CALLS:; $error_log_file receives the child's error_log()
+ * warnings so tests can assert whether one was logged.
+ */
+ private function acquireOnceChildCode(?string $error_log_file = null): string
+ {
+ $template = <<<'PHP'
+ use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+ use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync;
+
+ $root = __ROOT__;
+ require_once $root . '/vendor/composer/vendor/autoload.php';
+
+ $error_log_file = __ERROR_LOG_FILE__;
+ if ($error_log_file !== null) {
+ ini_set('error_log', $error_log_file);
+ }
+
+ $directory = new class implements LanguageFileDirectory {
+ public function getPrefix(): string
+ {
+ return 'stest';
+ }
+ public function getPath(): string
+ {
+ return 'x/';
+ }
+ public function getSuffix(): string
+ {
+ return '';
+ }
+ public function isLocal(): bool
+ {
+ return false;
+ }
+ };
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+ $client_data_dir = __CLIENT_DATA_DIR__;
+
+ try {
+ $calls = 0;
+ $result = MigratedLanguageFileSync::withOverlayLock(
+ $manager,
+ 'de',
+ 'stest',
+ $client_data_dir,
+ static function () use (&$calls, $client_data_dir): string {
+ $calls++;
+ // the inode of the lock file held right now - the lock file itself is
+ // removed afterwards (no overlay exists here)
+ clearstatcache();
+ $held = @lstat($client_data_dir . '/lang/stest/de/lock');
+ echo 'HELD:' . ($held === false ? '' : $held['ino']) . "\n";
+
+ return 'ok';
+ }
+ );
+ echo 'RESULT:' . $result . "\n";
+ echo 'CALLS:' . $calls . "\n";
+ } catch (\Throwable $e) {
+ echo 'EXCEPTION:' . get_class($e) . ':' . $e->getMessage() . "\n";
+ }
+ PHP;
+
+ return str_replace(
+ ['__ROOT__', '__CLIENT_DATA_DIR__', '__ERROR_LOG_FILE__'],
+ [
+ var_export(ILIAS_ABSOLUTE_PATH, true),
+ var_export($this->client_data_dir, true),
+ var_export($error_log_file, true),
+ ],
+ $template
+ );
+ }
+
+ /**
+ * @return array{pid: int, process: resource, pipes: array}
+ */
+ private function startAcquireOnceChild(?string $error_log_file = null): array
+ {
+ $started = ChildPhp::open(['-r', $this->acquireOnceChildCode($error_log_file)]);
+ if ($started === null) {
+ $this->fail('Could not start the child PHP process.');
+ }
+ [$process, $pipes] = $started;
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+ $status = proc_get_status($process);
+
+ return ['pid' => $status['pid'], 'process' => $process, 'pipes' => $pipes];
+ }
+
+ /**
+ * @param resource $process
+ * @param array $pipes
+ * @return array{finished: bool, output: string}
+ */
+ private function drainChild($process, array $pipes, float $timeout_seconds): array
+ {
+ $deadline = microtime(true) + $timeout_seconds;
+ $output = '';
+ do {
+ $output .= (string) stream_get_contents($pipes[1]);
+ $output .= (string) stream_get_contents($pipes[2]);
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ return ['finished' => true, 'output' => $output];
+ }
+ usleep(20_000);
+ } while (microtime(true) < $deadline);
+
+ proc_terminate($process, 9);
+ usleep(100_000);
+ $output .= (string) stream_get_contents($pipes[1]);
+ $output .= (string) stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ return ['finished' => false, 'output' => $output];
+ }
+
+ /**
+ * Kills a child left running by a failed assertion and closes its handles; safe to call after
+ * drainChild().
+ *
+ * @param array{pid: int, process: resource, pipes: array} $child
+ */
+ private function terminateChildIfRunning(array $child): void
+ {
+ if (is_resource($child['process'])) {
+ $status = proc_get_status($child['process']);
+ if ($status['running']) {
+ proc_terminate($child['process'], 9);
+ usleep(100_000);
+ }
+ }
+ foreach ($child['pipes'] as $pipe) {
+ if (is_resource($pipe)) {
+ @fclose($pipe);
+ }
+ }
+ if (is_resource($child['process'])) {
+ @proc_close($child['process']);
+ }
+ }
+
+ /**
+ * Replaces the file at $lock_file by a new inode locked via $next_handle before releasing
+ * $current_handle, so a waiter is only unblocked once the replacement is in place.
+ *
+ * @param resource $current_handle
+ * @return resource the new handle, still holding its lock
+ */
+ private function replaceLockedFile(string $lock_file, $current_handle)
+ {
+ $this->assertTrue((bool) @unlink($lock_file), 'could not unlink the lock file to replace it');
+ $next_handle = fopen($lock_file, 'c');
+ $this->assertNotFalse($next_handle, 'could not recreate the lock file');
+ $this->assertTrue(flock($next_handle, LOCK_EX));
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+
+ return $next_handle;
+ }
+
+ /**
+ * The lock file is replaced twice while a real process waits on it (synchronized via flock() and
+ * /proc, not timing); it must retry twice and end up holding the third, stable file.
+ */
+ public function testAWaiterThatLocksAnInodeDeletedInTheMeantimeRetriesAndEndsUpHoldingTheNewFile(): void
+ {
+ $this->requireProcFdSupport();
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+
+ $current_handle = fopen($lock_file, 'c');
+ $this->assertNotFalse($current_handle);
+ $this->assertTrue(flock($current_handle, LOCK_EX));
+
+ $child = $this->startAcquireOnceChild();
+ $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
+ try {
+ for ($round = 1; $round <= 2; $round++) {
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for attempt ' . $round
+ );
+ $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
+ }
+
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for the final, stable attempt'
+ );
+ clearstatcache(true, $lock_file);
+ $final_inode = lstat($lock_file)['ino'];
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ $current_handle = null;
+
+ $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
+ $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
+ $this->assertStringContainsString('RESULT:ok', $result['output']);
+
+ $this->assertStringContainsString('HELD:' . $final_inode . "\n", $result['output']);
+ clearstatcache(true, $lock_file);
+ $this->assertSame(
+ $final_inode,
+ lstat($lock_file)['ino'],
+ 'the waiter must end up holding the file that was actually left in place, not trigger a further replacement'
+ );
+ } finally {
+ if (is_resource($current_handle)) {
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ }
+ $this->terminateChildIfRunning($child);
+ }
+ }
+
+ /**
+ * When every attempt finds a replaced lock file, acquireLock() gives up with null and the callback
+ * runs exactly once, unlocked, with a warning logged.
+ */
+ public function testTheLockFileReplacedOnEveryAttemptExhaustsTheRetriesAndRunsCallbackOnceUnlockedWithAWarningLogged(): void
+ {
+ $this->requireProcFdSupport();
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+ $attempts = $this->lockAttempts();
+ $error_log_file = $this->client_data_dir . '-error-log-exhausted.txt';
+
+ $current_handle = fopen($lock_file, 'c');
+ $this->assertNotFalse($current_handle);
+ $this->assertTrue(flock($current_handle, LOCK_EX));
+
+ $child = $this->startAcquireOnceChild($error_log_file);
+ $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
+ try {
+ for ($round = 1; $round <= $attempts; $round++) {
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for attempt ' . $round
+ );
+ $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
+ }
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ $current_handle = null;
+
+ $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
+
+ $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
+ $this->assertStringContainsString(
+ 'RESULT:ok',
+ $result['output'],
+ 'exhausting LOCK_ATTEMPTS must fall back to running the callback unlocked, not throw'
+ );
+ $this->assertStringContainsString('CALLS:1', $result['output'], 'the callback must run exactly once');
+ $this->assertStringNotContainsString('EXCEPTION', $result['output']);
+
+ $this->assertFileExists($error_log_file, 'exhausting LOCK_ATTEMPTS must log a warning');
+ $warning = (string) file_get_contents($error_log_file);
+ $this->assertStringContainsString('removed or replaced', $warning);
+ $this->assertStringContainsString((string) $attempts, $warning);
+ } finally {
+ if (is_resource($current_handle)) {
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ }
+ $this->terminateChildIfRunning($child);
+ @unlink($error_log_file);
+ }
+ }
+
+ /**
+ * Replaced only on the first attempt: the second attempt acquires a real lock and no warning is
+ * logged.
+ */
+ public function testTheLockFileReplacedOnlyOnTheFirstAttemptStillAcquiresTheLockNormallyWithoutAWarning(): void
+ {
+ $this->requireProcFdSupport();
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+ $error_log_file = $this->client_data_dir . '-error-log-recovered.txt';
+
+ $current_handle = fopen($lock_file, 'c');
+ $this->assertNotFalse($current_handle);
+ $this->assertTrue(flock($current_handle, LOCK_EX));
+
+ $child = $this->startAcquireOnceChild($error_log_file);
+ $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
+ try {
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for the first attempt'
+ );
+ $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
+
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for the second, now-stable attempt'
+ );
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ $current_handle = null;
+
+ $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
+
+ $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
+ $this->assertStringContainsString('RESULT:ok', $result['output']);
+ $this->assertStringContainsString('CALLS:1', $result['output'], 'the callback must run exactly once');
+ $this->assertStringNotContainsString('EXCEPTION', $result['output']);
+
+ $this->assertSame(
+ '',
+ (string) @file_get_contents($error_log_file),
+ 'recovering on a later attempt must not log the "removed or replaced" warning'
+ );
+ } finally {
+ if (is_resource($current_handle)) {
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ }
+ $this->terminateChildIfRunning($child);
+ @unlink($error_log_file);
+ }
+ }
+
+ /**
+ * A symlink swapped in while the child waits is detected on a later attempt, not only before the first
+ * one.
+ */
+ public function testTheSymbolicLinkCheckAppliesOnEveryAttemptNotJustTheFirst(): void
+ {
+ $this->requireProcFdSupport();
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+ $decoy = $this->client_data_dir . '-decoy-lock-target';
+ file_put_contents($decoy, 'DECOY');
+
+ $current_handle = fopen($lock_file, 'c');
+ $this->assertNotFalse($current_handle);
+ $this->assertTrue(flock($current_handle, LOCK_EX));
+
+ $child = $this->startAcquireOnceChild();
+ $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
+ try {
+ $this->assertTrue(
+ $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
+ 'the child never opened the lock file for the first attempt'
+ );
+ $this->assertTrue((bool) @unlink($lock_file));
+ symlink($decoy, $lock_file);
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ $current_handle = null;
+
+ $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
+
+ $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
+ $this->assertStringContainsString(
+ 'RESULT:ok',
+ $result['output'],
+ 'a symlink discovered on a LATER attempt must fall back to running unlocked, not bubble up an exception'
+ );
+ $this->assertSame('DECOY', file_get_contents($decoy), 'the symlink target must never be touched');
+ $this->assertSame($decoy, readlink($lock_file), 'the symlink itself must be left in place');
+ } finally {
+ @unlink($decoy);
+ if (is_resource($current_handle)) {
+ flock($current_handle, LOCK_UN);
+ fclose($current_handle);
+ }
+ $this->terminateChildIfRunning($child);
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTest.php
new file mode 100644
index 000000000000..02344893ff5d
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTest.php
@@ -0,0 +1,289 @@
+runPhpWithTimeout($code, 3.0);
+ $this->assertTrue($result['finished'], 'the non-blocking lock probe itself must never hang: ' . $result['output']);
+
+ return (int) $result['exit_code'];
+ }
+
+ private function nestedLockChildCode(): string
+ {
+ $template = <<<'PHP'
+ use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectory;
+ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager;
+ use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync;
+
+ $root = __ROOT__;
+ require_once $root . '/vendor/composer/vendor/autoload.php';
+
+ $directory = new class implements LanguageFileDirectory {
+ public function getPrefix(): string
+ {
+ return 'stest';
+ }
+ public function getPath(): string
+ {
+ return 'x/';
+ }
+ public function getSuffix(): string
+ {
+ return '';
+ }
+ public function isLocal(): bool
+ {
+ return false;
+ }
+ };
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
+ $client_data_dir = __CLIENT_DATA_DIR__;
+
+ $log = [];
+ $result = MigratedLanguageFileSync::withOverlayLock(
+ $manager,
+ 'de',
+ 'stest',
+ $client_data_dir,
+ function () use ($manager, $client_data_dir, &$log) {
+ $log['outer'] = 1;
+
+ return MigratedLanguageFileSync::withOverlayLock(
+ $manager,
+ 'de',
+ 'stest',
+ $client_data_dir,
+ function () use (&$log) {
+ $log['inner'] = 1;
+
+ return 'nested-return';
+ }
+ );
+ }
+ );
+ $log['result'] = $result;
+ foreach ($log as $key => $value) {
+ echo $key . ':' . $value . "\n";
+ }
+ PHP;
+
+ return str_replace(
+ ['__ROOT__', '__CLIENT_DATA_DIR__'],
+ [var_export(ILIAS_ABSOLUTE_PATH, true), var_export($this->client_data_dir, true)],
+ $template
+ );
+ }
+
+ /**
+ * The re-entrancy guard lets a nested call return instead of blocking on its own lock; runs in a child
+ * with a hard timeout.
+ */
+ public function testANestedWithOverlayLockForTheSameModuleReturnsInsteadOfDeadlocking(): void
+ {
+ $this->seedLockDirectory();
+
+ $result = $this->runPhpWithTimeout($this->nestedLockChildCode(), 5.0);
+
+ $this->assertTrue(
+ $result['finished'],
+ 'a nested withOverlayLock() call for the same module must return instead of hanging: ' . $result['output']
+ );
+ $this->assertSame(0, $result['exit_code'], $result['output']);
+ $this->assertStringContainsString('outer:1', $result['output']);
+ $this->assertStringContainsString('inner:1', $result['output']);
+ $this->assertStringContainsString('result:nested-return', $result['output']);
+ }
+
+ /**
+ * A concurrent process is refused the lock while the callback runs and gets it as soon as it returns.
+ */
+ public function testAConcurrentProcessCannotAcquireTheLockWhileTheCallbackRunsButCanAfterwards(): void
+ {
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+
+ $probe_during = null;
+ MigratedLanguageFileSync::withOverlayLock(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ function () use (&$probe_during, $lock_file): void {
+ $probe_during = $this->probeLock($lock_file);
+ }
+ );
+ $probe_after = $this->probeLock($lock_file);
+
+ $this->assertSame(1, $probe_during, 'a concurrent process must not be able to lock the file while the callback runs');
+ $this->assertSame(0, $probe_after, 'the lock must be released once the callback returns');
+ }
+
+ /**
+ * Mutation: the `finally` release being removed or reordered - an exception in the callback must
+ * not leave the lock held forever.
+ */
+ public function testTheLockIsReleasedAfterAnExceptionInTheCallback(): void
+ {
+ $this->seedLockDirectory();
+ $lock_file = $this->lockFile();
+
+ try {
+ MigratedLanguageFileSync::withOverlayLock(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ static function (): void {
+ throw new RuntimeException('boom');
+ }
+ );
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertSame('boom', $e->getMessage());
+ }
+
+ $this->assertSame(0, $this->probeLock($lock_file), 'the lock must be released even after an exception in the callback');
+ }
+
+ /**
+ * A lock file that cannot be opened for writing is still locked via a read-only handle (a concurrent
+ * probe is refused).
+ */
+ public function testAcquiresARealLockViaTheReadOnlyFallbackWhenTheLockFileCannotBeOpenedForWriting(): void
+ {
+ if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
+ $this->markTestSkipped('Root can always open the lock file for writing; the read-only fallback cannot be exercised.');
+ }
+ $lock_file = $this->lockFile();
+ mkdir(dirname($lock_file), 0775, true);
+ touch($lock_file);
+ chmod($lock_file, 0444);
+
+ try {
+ $probe_during = null;
+ $ran = false;
+ MigratedLanguageFileSync::withOverlayLock(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ function () use (&$probe_during, &$ran, $lock_file): void {
+ $ran = true;
+ $probe_during = $this->probeLock($lock_file);
+ }
+ );
+
+ $this->assertTrue($ran, 'the callback must still run under the read-only-fallback lock');
+ $this->assertSame(1, $probe_during, 'the read-only fallback must still take a real, exclusive lock');
+ } finally {
+ chmod($lock_file, 0664);
+ }
+ }
+
+ /**
+ * Mutation: withOverlayLock() attempting to acquire a lock even without a manager - it must run
+ * $callback directly and touch nothing on disk.
+ */
+ public function testRunsWithoutALockOrDirectoryWhenTheManagerIsNull(): void
+ {
+ $ran = false;
+ $result = MigratedLanguageFileSync::withOverlayLock(
+ null,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ static function () use (&$ran): string {
+ $ran = true;
+
+ return 'ok';
+ }
+ );
+
+ $this->assertTrue($ran);
+ $this->assertSame('ok', $result);
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ }
+
+ /**
+ * Mutation: findDirectory() being bypassed - a module without a contributed directory must run
+ * unlocked, too.
+ */
+ public function testRunsWithoutALockWhenTheModuleHasNoContributedDirectory(): void
+ {
+ $ran = false;
+ MigratedLanguageFileSync::withOverlayLock(
+ $this->manager,
+ 'de',
+ 'unknown_module',
+ $this->client_data_dir,
+ static function () use (&$ran): void {
+ $ran = true;
+ }
+ );
+
+ $this->assertTrue($ran);
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ }
+
+ /**
+ * A module with a directory but neither an overlay directory nor a shipped .po runs unlocked and
+ * creates nothing.
+ */
+ public function testRunsWithoutALockWhenTheModuleIsNotShippedAndHasNoExistingOverlayDirectory(): void
+ {
+ $ran = false;
+ MigratedLanguageFileSync::withOverlayLock(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir,
+ static function () use (&$ran): void {
+ $ran = true;
+ },
+ ILIAS_ABSOLUTE_PATH
+ );
+
+ $this->assertTrue($ran);
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTestCase.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTestCase.php
new file mode 100644
index 000000000000..68679cb87a43
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTestCase.php
@@ -0,0 +1,119 @@
+client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_lock_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+ $this->directory = MigratedPoFixture::directory(self::MODULE, 'x/');
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+
+ parent::tearDown();
+ }
+
+ protected function lockFile(): string
+ {
+ return MigratedPoFixture::overlayLock(MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, 'de'));
+ }
+
+ /**
+ * Forces withOverlayLock() to take a real lock without needing a shipped `.po`: the overlay
+ * directory already existing is enough (see withOverlayLock()'s early-return condition).
+ */
+ protected function seedLockDirectory(): void
+ {
+ mkdir(dirname($this->lockFile()), 0775, true);
+ }
+
+ /**
+ * Runs $code in a child PHP process and kills it after $timeout_seconds, so a regression that
+ * deadlocks fails the test instead of hanging the suite.
+ *
+ * @return array{finished: bool, exit_code: ?int, output: string}
+ */
+ protected function runPhpWithTimeout(string $code, float $timeout_seconds): array
+ {
+ $started = ChildPhp::open(['-r', $code]);
+ if ($started === null) {
+ $this->fail('Could not start the child PHP process.');
+ }
+ [$process, $pipes] = $started;
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ $deadline = microtime(true) + $timeout_seconds;
+ $output = '';
+ do {
+ $output .= (string) stream_get_contents($pipes[1]);
+ $output .= (string) stream_get_contents($pipes[2]);
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ return ['finished' => true, 'exit_code' => $status['exitcode'], 'output' => $output];
+ }
+ usleep(20_000);
+ } while (microtime(true) < $deadline);
+
+ proc_terminate($process, 9);
+ usleep(100_000);
+ $output .= (string) stream_get_contents($pipes[1]);
+ $output .= (string) stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ return ['finished' => false, 'exit_code' => null, 'output' => $output];
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncOverlayFilesTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncOverlayFilesTest.php
new file mode 100644
index 000000000000..81650296dd87
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncOverlayFilesTest.php
@@ -0,0 +1,343 @@
+seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Servus']);
+ $po_before = file_get_contents($this->overlayBase() . '.po');
+ $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $po_inode = fileinode($this->overlayBase() . '.po');
+ $mo_inode = fileinode(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
+ $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'cached']]);
+
+ try {
+ $this->sync(['greeting' => 'Servus'], $refresh);
+
+ $this->assertSame([self::MODULE . '|de' => ['greeting' => 'cached']], $cache->getValue(), 'no invalidation');
+ } finally {
+ $cache->setValue(null, []);
+ }
+ clearstatcache();
+ $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
+ $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
+ $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the .po was not replaced');
+ $this->assertSame($mo_inode, fileinode(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo was not replaced');
+ }
+
+ public function testAMissingMoIsRecompiledEvenWhenThePoIsUnchanged(): void
+ {
+ // a local change: a shipped value creates no overlay (delta)
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Servus']);
+ $po_before = file_get_contents($this->overlayBase() . '.po');
+ $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $po_inode = fileinode($this->overlayBase() . '.po');
+ unlink(MigratedPoFixture::overlayMo($this->overlayBase()));
+
+ $this->sync(['greeting' => 'Servus']);
+
+ clearstatcache();
+ $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
+ $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the unchanged .po is not rewritten');
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * Self-healing: a `.mo` that does not hold exactly what TranslationCatalog::toMoString() compiles from the (unchanged)
+ * `.po` - garbage, truncated, empty, or the compiled content of another catalog - is rebuilt,
+ * byte for byte, although the `.po` itself is left untouched. ilLanguage serves the `.mo`, so
+ * without this a broken `.mo` would be served until the next actual value change.
+ */
+ #[DataProvider('staleMoContents')]
+ public function testAStaleMoNextToAnUnchangedPoIsRebuilt(\Closure $stale_content, bool $refresh): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->sync(['greeting' => 'Servus', 'farewell' => 'Tschüss']);
+ $po_before = file_get_contents($this->overlayBase() . '.po');
+ $po_inode = fileinode($this->overlayBase() . '.po');
+ $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
+ file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), $stale_content($mo_before));
+ $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
+ $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'stale']]);
+
+ try {
+ $this->sync(['greeting' => 'Servus', 'farewell' => 'Tschüss'], $refresh);
+
+ $this->assertSame([], $cache->getValue(), 'the rebuilt .mo invalidates the read cache');
+ } finally {
+ $cache->setValue(null, []);
+ }
+
+ clearstatcache();
+ $mo_after = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $this->assertSame($mo_before, $mo_after);
+ $this->assertSame(\ILIAS\Language\ComponentTranslation\ShippedTranslations::compileCatalog(MigratedPoFixture::readPo($this->overlayBase() . '.po'), self::MODULE, false)['mo'], $mo_after);
+ // "farewell" carries the shipped value and is not in the overlay
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
+ $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the unchanged .po is not rewritten');
+ }
+
+ /**
+ * @return array>
+ */
+ public static function staleMoContents(): array
+ {
+ return [
+ 'garbage' => [static fn(string $mo): string => 'SENTINEL', false],
+ 'truncated' => [static fn(string $mo): string => substr($mo, 0, intdiv(strlen($mo), 2)), false],
+ 'one byte missing' => [static fn(string $mo): string => substr($mo, 0, -1), false],
+ 'empty' => [static fn(string $mo): string => '', false],
+ 'another catalog' => [
+ static fn(string $mo): string => MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Veraltet'])->toMoString(),
+ false,
+ ],
+ 'garbage, reconciling write' => [static fn(string $mo): string => 'SENTINEL', true],
+ ];
+ }
+
+ public function testAChangedValueRewritesBothFiles(): void
+ {
+ // a first local change creates the overlay to start from
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Moin']);
+ file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), 'SENTINEL');
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * An entry that enters the overlay with a write that knows when it was changed locally (setup
+ * update: lng_data.local_change) keeps that time - not the time of the write. An entry already in
+ * the overlay keeps its own bookkeeping.
+ */
+ public function testANewOverlayEntryTakesItsLocalChangeDateFromTheGivenDates(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ self::MODULE,
+ ['greeting' => 'Servus', 'farewell' => 'Pfiat di'],
+ $this->client_data_dir,
+ true,
+ null,
+ null,
+ ['greeting' => '2026-10-02 09:36:30', 'farewell' => 'not a timestamp']
+ );
+
+ $this->assertSame('2026-10-02T09:36:30Z', LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'greeting')));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'farewell')), 'an unreadable date: the time of the write');
+ $this->assertNotSame('2026-10-02T09:36:30Z', LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'farewell')));
+ }
+
+ public function testASyncInvalidatesTheIlLanguageReadCache(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
+ $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'stale'], 'other|de' => ['x' => 'y']]);
+
+ try {
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame(['other|de' => ['x' => 'y']], $cache->getValue());
+ } finally {
+ $cache->setValue(null, []);
+ }
+ }
+
+ /**
+ * gettext/gettext treats a falsy translation as absent; the adapter's workaround must hold on
+ * the whole way through sync(): "0" (it occurs in the legacy ilias_fr.lang/ilias_pl.lang) is
+ * written as `msgstr "0"`, served from the `.mo` as "0", is no local change when shipped that
+ * way - and re-syncing it is a no-op (the `.mo` byte comparison must see identical output).
+ */
+ public function testTheValueZeroSurvivesTheSyncAndResyncingIsANoOp(): void
+ {
+ // a shipped "0" is not written, so the local value "0" (shipped is "1") goes through the overlay files
+ $this->seedShipped(['zero' => '1', 'greeting' => 'Hallo']);
+
+ $this->sync(['zero' => '0', 'greeting' => 'Hallo']);
+
+ $po = file_get_contents($this->overlayBase() . '.po');
+ $this->assertStringContainsString("msgid \"zero\"\nmsgstr \"0\"\n", $po);
+ $this->assertSame(['zero' => '0'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ $zero = $this->overlayPo()->find(null, 'zero');
+ $this->assertSame('1', LocalChangeComments::getOriginal($zero));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($zero));
+
+ $po_inode = fileinode($this->overlayBase() . '.po');
+ $mo_inode = fileinode(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $this->sync(['zero' => '0', 'greeting' => 'Hallo'], true);
+ $this->sync(['zero' => '0', 'greeting' => 'Hallo']);
+
+ clearstatcache();
+ $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the .po was not replaced');
+ $this->assertSame($mo_inode, fileinode(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo was not replaced');
+ }
+
+ public function testAValueChangedToZeroIsALocalChangeAndServedAsZero(): void
+ {
+ $this->seedShipped(['count' => '1']);
+ $this->sync(['count' => '1']);
+
+ $this->sync(['count' => '0']);
+
+ $count = $this->overlayPo()->find(null, 'count');
+ $this->assertSame('0', $count?->getTranslation());
+ $this->assertSame('1', LocalChangeComments::getOriginal($count));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($count));
+ $this->assertSame(['count' => '0'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * A shipped value with line breaks or backslashes is stored as "original" losslessly: re-syncing it is no local
+ * change, neither on creation nor on a reconciling write, and produces byte-identical files.
+ */
+ public function testAnUnchangedMultiLineShippedValueIsNoLocalChangeAndResyncingIsANoOp(): void
+ {
+ $shipped = ['lf' => "Zeile 1\nZeile 2", 'crlf' => "Zeile 1\r\nZeile 2", 'backslash' => 'C:\\pfad', 'literal' => 'a\\nb'];
+ $this->seedShipped($shipped);
+
+ $this->sync($shipped);
+ $this->sync($shipped, true);
+ $this->sync($shipped);
+
+ // an unchanged multi-line value is recognised as the shipped one: no overlay is written
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $translations = MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ foreach ($shipped as $identifier => $value) {
+ $this->assertSame($value, $translations[$identifier]['value'], $identifier);
+ $this->assertFalse($translations[$identifier]['local_change'], $identifier);
+ }
+ }
+
+ public function testChangingOnlyTheLineBreakOfAMultiLineValueIsALocalChange(): void
+ {
+ $this->seedShipped(['lf' => "Zeile 1\nZeile 2"]);
+ $this->sync(['lf' => "Zeile 1\nZeile 2"]);
+
+ $this->sync(['lf' => "Zeile 1\r\nZeile 2"]);
+
+ $entry = $this->overlayPo()->find(null, 'lf');
+ $this->assertSame("Zeile 1\nZeile 2", LocalChangeComments::getOriginal($entry));
+ $this->assertNotNull(LocalChangeComments::getLocalChange($entry));
+ }
+
+ /**
+ * An overlay written by an earlier version has the multi-line original flattened into spaces - a
+ * reconciling write (refresh) replaces it by the losslessly escaped shipped value and clears the
+ * resulting false local change.
+ */
+ public function testAReconcilingWriteRepairsALegacyFlattenedOriginal(): void
+ {
+ $this->seedShipped(['lf' => "Zeile 1\nZeile 2"]);
+ $catalog = MigratedPoFixture::catalog(self::MODULE, ['lf' => ['value' => "Zeile 1\nZeile 2", 'local_change' => '2020-01-01T00:00:00Z']]);
+ $catalog->find(self::MODULE, 'lf')->addTranslatorComment('original: Zeile 1 Zeile 2');
+ MigratedPoFixture::writePair($this->overlayBase(), $catalog);
+
+ $this->sync(['lf' => "Zeile 1\nZeile 2"], true);
+
+ // the value is the shipped one, so the legacy entry with its false local change leaves the overlay instead of
+ // being repaired
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertFalse(MigratedLanguageFileSync::loadModuleTranslations(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ $this->client_data_dir
+ )['lf']['local_change']);
+ }
+
+ /**
+ * An "original: " comment of the legacy format containing a backslash is read verbatim: an admin
+ * edit of another entry keeps the untouched entry unchanged and not locally changed.
+ */
+ public function testALegacyOriginalWithABackslashIsReadVerbatimByAnAdminEdit(): void
+ {
+ // the shipped value changed since, so "path" is still part of the delta (a value equal to the shipped one would
+ // leave the overlay)
+ $this->seedShipped(['path' => 'C:\\neu', 'greeting' => 'Hallo']);
+ $catalog = MigratedPoFixture::catalog(self::MODULE, ['path' => 'C:\\pfad', 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
+ $catalog->find(self::MODULE, 'path')->addTranslatorComment('original: C:\\pfad');
+ MigratedPoFixture::writePair($this->overlayBase(), $catalog);
+
+ $this->sync(['path' => 'C:\\pfad', 'greeting' => 'Servus']);
+
+ $path = $this->overlayPo()->find(null, 'path');
+ $this->assertSame('C:\\pfad', LocalChangeComments::getOriginal($path));
+ $this->assertNull(LocalChangeComments::getLocalChange($path));
+ $this->assertContains('original: C:\\pfad', $path->getTranslatorComments(), 'an admin edit never moves the original');
+ }
+
+ public function testLoadModuleTranslationsReturnsTheDecodedMultiLineOriginal(): void
+ {
+ $this->seedShipped(['lf' => 'Zeile 1']);
+ $this->seedOverlay(['lf' => ['value' => "Zeile 1\nZeile 2", 'original' => "Zeile 1\nZeile 2"]]);
+
+ $this->assertSame(
+ "Zeile 1\nZeile 2",
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['lf']['original']
+ );
+ }
+
+ public function testRefreshKeepsAnEscapedOriginalOfAnEntryTheShippedPoNoLongerContains(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay([
+ 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'],
+ 'dropped' => ['value' => "Neu\nZeile", 'original' => "Alt\nZeile", 'local_change' => '2020-01-01T00:00:00Z'],
+ ]);
+
+ $this->sync(['greeting' => 'Hallo', 'dropped' => "Neu\nZeile"], true);
+
+ $dropped = $this->overlayPo()->find(null, 'dropped');
+ $this->assertSame("Alt\nZeile", LocalChangeComments::getOriginal($dropped));
+ $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($dropped));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralHelpersTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralHelpersTest.php
new file mode 100644
index 000000000000..38b9c3240892
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralHelpersTest.php
@@ -0,0 +1,280 @@
+ 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge NEU'];
+
+ $this->assertSame(['item' => 'Einträge NEU'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ /**
+ * A form missing from $entries counts with its shipped value, not as empty (a partial submission must not corrupt
+ * the default value).
+ */
+ public function testAFormMissingFromEntriesFallsBackToItsShippedValue(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item [1]' => 'Einträge NEU']; // form 0 not submitted at all
+
+ $this->assertSame(['item' => 'Einträge NEU'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ /** Entries keep the position of their identifier's first form key. */
+ public function testThePluralIdentifierIsPlacedAtThePositionOfItsFirstFormKey(): void
+ {
+ $shipped = ['before' => 'A', 'item [0]' => 'Eintrag', 'item [1]' => 'Einträge', 'after' => 'B'];
+
+ $this->assertSame(
+ ['before', 'item', 'after'],
+ array_keys(MigratedLanguageFileSync::collapsePluralForms($shipped, $shipped))
+ );
+ }
+
+ /** A form index the shipped message does not declare (e.g. "item [5]" with nplurals=2) is ignored. */
+ public function testAFormIndexOutsideTheShippedFormCountIsIgnored(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge NEU', 'item [5]' => 'Geister-Form'];
+
+ $collapsed = MigratedLanguageFileSync::collapsePluralForms($entries, $shipped);
+
+ $this->assertSame(['item' => 'Einträge NEU'], $collapsed);
+ }
+
+ /** A plain value under the plural identifier loses to the form keys. */
+ public function testAPlainValueNextToFormsIsDroppedInFavourOfTheForms(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item' => 'Veraltet', 'item [0]' => 'Eintrag', 'item [1]' => 'Einträge NEU'];
+
+ $this->assertSame(['item' => 'Einträge NEU'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ public function testEntriesUnrelatedToAnyPluralMessageAreKeptAsIs(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge', 'plain' => 'X'];
+ $entries = ['plain' => 'Y', 'other' => 'Z'];
+
+ $this->assertSame(['plain' => 'Y', 'other' => 'Z'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ public function testWithoutAnyPluralMessageInShippedEntriesIsAnIdentity(): void
+ {
+ $shipped = ['plain' => 'X'];
+ $entries = ['plain' => 'Y', 'other [0]' => 'looks like a form but is not one'];
+
+ $this->assertSame($entries, MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ /**
+ * An identifier shipped as its own entry and with form keys (contradictory data) is not treated as plural
+ * (pluralFormCounts()' guard).
+ */
+ public function testAnIdentifierShippedBothAsItsOwnEntryAndWithFormKeysIsNotTreatedAsPlural(): void
+ {
+ $shipped = ['item' => 'Eigener Wert', 'item [0]' => 'Form 0', 'item [1]' => 'Form 1'];
+
+ $this->assertSame($shipped, MigratedLanguageFileSync::collapsePluralForms($shipped, $shipped));
+ }
+
+ /** Three forms (Czech-shaped): the identifier's value is the LAST form's, not msgstr[1]; see PluralForms. */
+ public function testCollapsesToTheLastFormForAThreeFormLanguage(): void
+ {
+ $shipped = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => 'mnoho'];
+ $entries = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => 'mnoho NEU'];
+
+ $this->assertSame(['item' => 'mnoho NEU'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ /**
+ * An empty last form falls back to the closest non-empty form before it (PluralForms::defaultValueForCount()), not
+ * to form 0.
+ */
+ public function testCollapsesToTheClosestNonEmptyFormWhenTheLastFormIsEmpty(): void
+ {
+ $shipped = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => 'mnoho'];
+ $entries = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => ''];
+
+ $this->assertSame(['item' => 'dva'], MigratedLanguageFileSync::collapsePluralForms($entries, $shipped));
+ }
+
+ /**
+ * A plain value under a plural identifier (a local change from before the module had plurals, or a lng_data value)
+ * becomes its default form's value.
+ */
+ public function testAPlainValueOfAPluralIdentifierBecomesItsDefaultForm(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item' => 'Alter DB-Wert'];
+
+ $this->assertSame(['item [1]' => 'Alter DB-Wert'], MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ /** More than two forms: the plain value becomes the LAST form's key ("item [2]" for three forms). */
+ public function testAPlainValueOfAThreeFormLanguagesPluralIdentifierBecomesTheLastForm(): void
+ {
+ $shipped = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => 'mnoho'];
+ $entries = ['item' => 'Alter DB-Wert'];
+
+ $this->assertSame(['item [2]' => 'Alter DB-Wert'], MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ public function testAPlainValueOfASingleFormLanguagesPluralIdentifierBecomesFormZero(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag']; // nplurals=1
+ $entries = ['item' => 'Alter DB-Wert'];
+
+ $this->assertSame(['item [0]' => 'Alter DB-Wert'], MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ /** An already present default form key wins; the plain value is dropped. */
+ public function testAPlainValueIsDroppedWhenItsDefaultFormKeyIsAlreadyPresent(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item' => 'Alter DB-Wert', 'item [1]' => 'Neue Form 1'];
+
+ $this->assertSame(['item [1]' => 'Neue Form 1'], MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ /** A present non-default form key does not stop the plain value from mapping to the absent default form key. */
+ public function testAPlainValueIsStillMappedWhenOnlyANonDefaultFormIsAlreadyPresent(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item' => 'Alter DB-Wert', 'item [0]' => 'Neue Form 0'];
+
+ $this->assertSame(
+ ['item [0]' => 'Neue Form 0', 'item [1]' => 'Alter DB-Wert'],
+ MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped)
+ );
+ }
+
+ public function testEntriesWithNoPlainPluralIdentifierAreUnaffected(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['plain' => 'X', 'item [0]' => 'A', 'item [1]' => 'B'];
+
+ $this->assertSame($entries, MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ /** Values of any type round-trip: loadOverlay()/resolveMigratedModule() pass a boolean "is local change" map. */
+ public function testPreservesNonStringValueTypes(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+ $entries = ['item' => true];
+
+ $this->assertSame(['item [1]' => true], MigratedLanguageFileSync::mapLegacyPluralValues($entries, $shipped));
+ }
+
+ public function testPluralMessageOfResolvesAFormKeyToItsIdentifier(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+
+ $this->assertSame('item', MigratedLanguageFileSync::pluralMessageOf('item [1]', $shipped));
+ }
+
+ public function testPluralMessageOfResolvesTheBareIdentifierToo(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+
+ $this->assertSame('item', MigratedLanguageFileSync::pluralMessageOf('item', $shipped));
+ }
+
+ public function testPluralMessageOfIsNullForAnOrdinaryIdentifier(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge', 'plain' => 'X'];
+
+ $this->assertNull(MigratedLanguageFileSync::pluralMessageOf('plain', $shipped));
+ }
+
+ public function testPluralMessageOfIsNullForAKeyThatMerelyLooksLikeAFormKey(): void
+ {
+ $shipped = ['plain [0]' => 'X']; // shipped as its own entry, not via other forms
+ $shipped['plain'] = 'irrelevant';
+
+ $this->assertNull(MigratedLanguageFileSync::pluralMessageOf('plain [0]', $shipped));
+ }
+
+ public function testPluralMessageOfIsNullForAnUnknownIdentifier(): void
+ {
+ $this->assertNull(MigratedLanguageFileSync::pluralMessageOf('unknown [0]', []));
+ $this->assertNull(MigratedLanguageFileSync::pluralMessageOf('unknown', []));
+ }
+
+ /**
+ * The plain identifier of a plural message (import line, "add new variable") maps to the key of its default (last)
+ * form.
+ */
+ public function testDefaultFormKeyOfMapsThePlainIdentifierToItsLastFormKey(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+
+ $this->assertSame('item [1]', MigratedLanguageFileSync::defaultFormKeyOf('item', $shipped));
+ }
+
+ public function testDefaultFormKeyOfMapsToTheLastFormOfAThreeFormLanguage(): void
+ {
+ $shipped = ['item [0]' => 'jeden', 'item [1]' => 'dva', 'item [2]' => 'mnoho'];
+
+ $this->assertSame('item [2]', MigratedLanguageFileSync::defaultFormKeyOf('item', $shipped));
+ }
+
+ /** A key that already is a form key is no plain identifier: null. */
+ public function testDefaultFormKeyOfIsNullForAFormKeyItself(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'];
+
+ $this->assertNull(MigratedLanguageFileSync::defaultFormKeyOf('item [0]', $shipped));
+ $this->assertNull(MigratedLanguageFileSync::defaultFormKeyOf('item [1]', $shipped));
+ }
+
+ /** A singular key next to a plural message (e.g. "item_singular") is an ordinary identifier: null. */
+ public function testDefaultFormKeyOfIsNullForASingularKey(): void
+ {
+ $shipped = ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge', 'item_singular' => 'Ein Eintrag'];
+
+ $this->assertNull(MigratedLanguageFileSync::defaultFormKeyOf('item_singular', $shipped));
+ }
+
+ public function testDefaultFormKeyOfIsNullForAnUnknownIdentifier(): void
+ {
+ $this->assertNull(MigratedLanguageFileSync::defaultFormKeyOf('unknown', []));
+ }
+
+ /** An identifier shipped as its own entry and with form keys is not mapped either (pluralMessageOf()'s guard). */
+ public function testDefaultFormKeyOfIsNullForAnIdentifierShippedBothAsItsOwnEntryAndWithFormKeys(): void
+ {
+ $shipped = ['item' => 'Eigener Wert', 'item [0]' => 'Form 0', 'item [1]' => 'Form 1'];
+
+ $this->assertNull(MigratedLanguageFileSync::defaultFormKeyOf('item', $shipped));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralOverlayTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralOverlayTest.php
new file mode 100644
index 000000000000..7767ad435f78
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralOverlayTest.php
@@ -0,0 +1,301 @@
+fixture_directory = __DIR__ . '/tmp-plural-sync-fixtures-' . bin2hex(random_bytes(4));
+ mkdir($this->fixture_directory, 0775, true);
+ $this->client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_plural_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+
+ $this->directory = MigratedPoFixture::directory(
+ self::MODULE,
+ 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/'
+ );
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->fixture_directory);
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+
+ parent::tearDown();
+ }
+
+ private function shippedPo(string $lang_key = 'de'): string
+ {
+ return $this->fixture_directory . '/' . self::MODULE . '_' . $lang_key . '.po';
+ }
+
+ private function overlayBase(string $lang_key = 'de'): string
+ {
+ return MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, $lang_key);
+ }
+
+ private function overlayPo(): TranslationCatalog
+ {
+ return MigratedPoFixture::readPo($this->overlayBase() . '.po');
+ }
+
+ /**
+ * @param array> $entries plain value, or the forms of a plural
+ * message (msgstr[0], msgstr[1], ...) of the identifier
+ */
+ private function seedShipped(array $entries, string $header = 'nplurals=2; plural=(n != 1);'): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ $catalog->setHeader('Plural-Forms', $header);
+ foreach ($entries as $identifier => $value) {
+ $entry = new TranslationEntry(self::MODULE, (string) $identifier);
+ if (is_array($value)) {
+ $entry->setPlural($identifier . '_plural', $value);
+ } else {
+ $entry->translate($value);
+ }
+ $catalog->add($entry);
+ }
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+ }
+
+ /**
+ * @param array $entries identifier => value, form keys (PluralFormKey::of())
+ * included for a plural message
+ */
+ private function sync(array $entries, bool $refresh = false): void
+ {
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ ILIAS_ABSOLUTE_PATH,
+ 'de',
+ self::MODULE,
+ $entries,
+ $this->client_data_dir,
+ $refresh
+ );
+ }
+
+ private function overlayEntry(string $identifier): ?TranslationEntry
+ {
+ return $this->overlayPo()->find(null, $identifier);
+ }
+
+ /**
+ * @param array $dates identifier or form key => database timestamp
+ */
+ private function syncWithLocalChangeDates(array $dates): void
+ {
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ ILIAS_ABSOLUTE_PATH,
+ 'de',
+ self::MODULE,
+ ['item [0]' => 'Eintrag NEU', 'item [1]' => 'Einträge NEU'],
+ $this->client_data_dir,
+ false,
+ null,
+ null,
+ $dates
+ );
+ }
+
+ /**
+ * A new plural overlay entry whose dates are known only per form (rows "item [0]", "item [1]" of
+ * lng_data) gets the latest of them as "local_change" - neither "now" nor the first form's date;
+ * a form of another message ("itemx [0]") is not considered.
+ */
+ public function testANewPluralEntryTakesTheLatestLocalChangeDateOfItsForms(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+
+ $this->syncWithLocalChangeDates([
+ 'item [0]' => '2021-05-06 07:08:09',
+ 'item [1]' => '2020-01-02 03:04:05',
+ 'itemx [0]' => '2023-01-01 00:00:00',
+ ]);
+
+ $this->assertSame('2021-05-06T07:08:09Z', LocalChangeComments::getLocalChange($this->overlayEntry('item')));
+ }
+
+ /**
+ * A date under the identifier itself wins over the dates of the forms; an unreadable date of a
+ * form is ignored.
+ */
+ public function testADateUnderThePluralIdentifierWinsOverTheFormDates(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+
+ $this->syncWithLocalChangeDates(['item' => '2019-02-03 04:05:06', 'item [0]' => '2021-05-06 07:08:09']);
+
+ $this->assertSame('2019-02-03T04:05:06Z', LocalChangeComments::getLocalChange($this->overlayEntry('item')));
+ }
+
+ public function testAnUnreadableFormDateIsIgnoredAndWithoutAnyReadableDateTheEntryGetsTheCurrentTime(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+ $before = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+
+ $this->syncWithLocalChangeDates(['item [0]' => 'garbage', 'item [1]' => '2021-05-06 07:08:09']);
+ $this->assertSame('2021-05-06T07:08:09Z', LocalChangeComments::getLocalChange($this->overlayEntry('item')));
+
+ $this->tearDownOverlay();
+ $this->syncWithLocalChangeDates(['item [0]' => 'garbage']);
+ $change = LocalChangeComments::getLocalChange($this->overlayEntry('item'));
+ $this->assertNotNull($change);
+ $this->assertGreaterThanOrEqual($before->format('Y-m-d\TH:i:s\Z'), $change);
+ }
+
+ private function tearDownOverlay(): void
+ {
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+ mkdir($this->client_data_dir, 0775, true);
+ }
+
+ /**
+ * Only one of two forms is locally changed: the overlay still carries the whole plural entry
+ * (both forms - not just the changed one), and gets an "original" comment per form (the shipped
+ * value each form is compared against later).
+ */
+ public function testOnlyOneFormChangedWritesTheWholeEntryWithAnOriginalPerForm(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+
+ $this->sync(['item [0]' => 'Eintrag', 'item [1]' => 'Einträge NEU']);
+
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry, 'the plural message must be in the overlay');
+ $this->assertTrue($entry->isPlural());
+ $this->assertSame(['Eintrag', 'Einträge NEU'], $entry->getPluralTranslations());
+ $this->assertSame('Eintrag', LocalChangeComments::getOriginal($entry, 0));
+ $this->assertSame('Einträge', LocalChangeComments::getOriginal($entry, 1));
+ }
+
+ /**
+ * Once every form is back to its shipped value, the whole entry disappears from the overlay - a
+ * still-locally-changed, unrelated entry of the same module stays untouched.
+ */
+ public function testAllFormsBackToShippedRemovesTheEntryButKeepsOtherLocalChanges(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge'], 'plain' => 'X']);
+ $this->sync(['item [0]' => 'Eintrag', 'item [1]' => 'GEÄNDERT', 'plain' => 'Anders']);
+ $this->assertNotNull($this->overlayEntry('item'), 'precondition: the plural message is in the overlay');
+
+ $this->sync(['item [0]' => 'Eintrag', 'item [1]' => 'Einträge', 'plain' => 'Anders']);
+
+ $this->assertNull($this->overlayEntry('item'));
+ $plain = $this->overlayEntry('plain');
+ $this->assertNotNull($plain, 'the unrelated local change must survive');
+ $this->assertSame('Anders', $plain->getTranslation());
+ }
+
+ /**
+ * Submitting an empty value for one form resets exactly that form to its shipped value - even
+ * while the overlay already held a different, locally changed value for it - without touching the
+ * other, still locally changed form.
+ */
+ public function testAnEmptyFormValueResetsOnlyThatFormToShippedKeepingTheOtherFormsLocalChange(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => 'FORM1-LOKAL']);
+
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => '']);
+
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry);
+ $this->assertSame(['FORM0-LOKAL', 'Einträge'], $entry->getPluralTranslations());
+ }
+
+ /**
+ * $refresh_original_from_shipped=true (a Setup update) updates every form's "original" to the new
+ * shipped value - including the one form that is not locally overridden, whose value itself then
+ * simply follows the new shipped value too - while a genuinely locally changed form keeps its own
+ * value.
+ */
+ public function testRefreshOriginalFromShippedUpdatesOriginalOfEveryFormToTheNewShippedValue(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => 'Einträge']);
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry);
+ $this->assertSame('Einträge', LocalChangeComments::getOriginal($entry, 1), 'precondition');
+
+ // the shipped file changes only form 1
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge NEU']]);
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => 'Einträge NEU'], refresh: true);
+
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry);
+ $this->assertSame(['FORM0-LOKAL', 'Einträge NEU'], $entry->getPluralTranslations());
+ $this->assertSame('Eintrag', LocalChangeComments::getOriginal($entry, 0));
+ $this->assertSame('Einträge NEU', LocalChangeComments::getOriginal($entry, 1));
+ }
+
+ /**
+ * Without $refresh_original_from_shipped (an ordinary form save), the "original" of an existing
+ * overlay entry is left exactly as it was, even though the shipped file changed in the meantime -
+ * only an explicit refresh (Setup update / "remove local changes") may move it.
+ */
+ public function testWithoutRefreshTheExistingOriginalIsLeftUntouchedWhenTheShippedValueChanges(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => 'Einträge']);
+
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge NEU']]);
+ // form 1's value must be resubmitted as its (new) current value to stay out of the delta -
+ // an ordinary caller always reads currentModuleContent() first, which already reflects it
+ $this->sync(['item [0]' => 'FORM0-LOKAL', 'item [1]' => 'Einträge NEU'], refresh: false);
+
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry);
+ $this->assertSame('Einträge', LocalChangeComments::getOriginal($entry, 1), 'original stays the OLD shipped value');
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralRuleChangeTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralRuleChangeTest.php
new file mode 100644
index 000000000000..9949ba26c7d2
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralRuleChangeTest.php
@@ -0,0 +1,389 @@
+ 2): the local
+ * values of an overlay written under the previous rule follow the new rule by meaning, not by index -
+ * mapPluralForms(), withPluralRuleOf() and where they are applied (readers, sync(), the merge into the
+ * shipped `.po`).
+ */
+class MigratedLanguageFileSyncPluralRuleChangeTest extends MigratedLanguageFileSyncTestCase
+{
+ private const string ONE_FORM = 'nplurals=1; plural=0;';
+ private const string TWO_FORMS = 'nplurals=2; plural=(n != 1);';
+ private const string THREE_FORMS = 'nplurals=3; plural=(n == 1 ? 0 : n >= 2 && n <= 4 ? 1 : 2);';
+
+ /**
+ * @param list $previous_forms
+ * @param array $expected new form => previous form
+ */
+ #[DataProvider('mappings')]
+ public function testMapPluralFormsGivesAFormThePreviousFormWithItsMeaning(
+ string $previous,
+ string $rule,
+ array $previous_forms,
+ array $expected
+ ): void {
+ $this->assertSame($expected, MigratedLanguageFileSync::mapPluralForms(
+ PluralForms::fromHeader($previous),
+ PluralForms::fromHeader($rule),
+ $previous_forms
+ ));
+ }
+
+ /**
+ * @return array, 3: array}>
+ */
+ public static function mappings(): array
+ {
+ $four_ish = 'nplurals=3; plural=(n == 1 ? 0 : n <= 4 ? 1 : 2);';
+
+ return [
+ 'unambiguous: the new forms 1 and 2 were served by the old form 1' => [
+ self::TWO_FORMS, self::THREE_FORMS, ['E', 'V'], [0 => 0, 1 => 1, 2 => 1],
+ ],
+ 'unambiguous: every form of a 1-form language' => [
+ self::ONE_FORM, self::TWO_FORMS, ['L'], [0 => 0, 1 => 0],
+ ],
+ 'ambiguous, the old forms carry the same value' => [
+ self::THREE_FORMS, self::TWO_FORMS, ['E', 'X', 'X'], [0 => 0, 1 => 1],
+ ],
+ 'ambiguous, the new default form takes the old default form' => [
+ self::THREE_FORMS, self::TWO_FORMS, ['E', 'FEW', 'VIELE'], [0 => 0, 1 => 2],
+ ],
+ 'ambiguous, no default form: left out' => [
+ self::THREE_FORMS, $four_ish, ['E', 'FEW', 'VIELE'], [0 => 0, 2 => 2],
+ ],
+ 'ambiguous with equal values, no default form: taken over' => [
+ self::THREE_FORMS, $four_ish, ['E', 'X', 'X'], [0 => 0, 1 => 1, 2 => 2],
+ ],
+ 'a form no sampled quantity selects: left out' => [
+ self::TWO_FORMS, 'nplurals=3; plural=(n == 1 ? 0 : 1);', ['E', 'V'], [0 => 0, 1 => 1],
+ ],
+ ];
+ }
+
+ public function testWithPluralRuleOfReturnsTheSameObjectIfTheRuleIsTheSame(): void
+ {
+ $overlay = self::pluralCatalog(self::THREE_FORMS, ['item' => ['E', 'LOKAL', 'VIELE']]);
+ $shipped = self::pluralCatalog(self::THREE_FORMS, ['item' => ['E', 'F', 'V']]);
+
+ $this->assertSame($overlay, MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over));
+ $this->assertSame([], $not_taken_over);
+ }
+
+ /**
+ * 3 -> 2 forms: the local default form is the one the new default form gets (it is the catch-all
+ * form of both rules) - with its own "original" -, the local middle form has no form with its
+ * meaning and is reported with its form key (of the previous rule).
+ */
+ public function testWithPluralRuleOfKeepsTheLocalDefaultFormWhenTheNumberOfFormsShrinks(): void
+ {
+ $overlay = self::pluralCatalog(
+ self::THREE_FORMS,
+ ['item' => ['Eintrag', 'LOKAL-FEW', 'LOKAL-VIELE']],
+ ['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele']]
+ );
+ $shipped = self::pluralCatalog(self::TWO_FORMS, ['item' => ['Eintrag', 'Einträge']]);
+
+ $result = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over);
+
+ $this->assertNotSame($overlay, $result);
+ $this->assertSame(self::TWO_FORMS, $result->getHeader('Plural-Forms'));
+ $item = $result->find(self::MODULE, 'item');
+ $this->assertSame(['Eintrag', 'LOKAL-VIELE'], $item?->getPluralTranslations());
+ $this->assertSame('Eintrag', LocalChangeComments::getOriginal($item, 0));
+ $this->assertSame('Einträge-viele', LocalChangeComments::getOriginal($item, 1), 'the original of the form its value came from');
+ $this->assertSame(['item [1]'], $not_taken_over);
+ }
+
+ /**
+ * A form no previous form maps to is no local change: it has the shipped value as value and as
+ * "original". A value that equals its original was no local change, so it is not reported.
+ */
+ public function testWithPluralRuleOfGivesAFormWithoutAPreviousFormTheShippedValueAndNoLocalChange(): void
+ {
+ $overlay = self::pluralCatalog(self::TWO_FORMS, ['item' => ['Eintrag', 'LOKAL']], ['item' => ['Eintrag', 'Einträge']]);
+ $shipped = self::pluralCatalog('nplurals=3; plural=(n == 1 ? 0 : 1);', ['item' => ['Eintrag', 'Einträge', 'Eintrage-nie']]);
+
+ $result = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over);
+
+ $item = $result->find(self::MODULE, 'item');
+ $this->assertSame(['Eintrag', 'LOKAL', 'Eintrage-nie'], $item?->getPluralTranslations());
+ $this->assertSame('Eintrage-nie', LocalChangeComments::getOriginal($item, 2));
+ $this->assertSame([], $not_taken_over);
+ }
+
+ public function testWithPluralRuleOfLeavesTheInputUnchangedAndKeepsSingularEntriesAndOtherModules(): void
+ {
+ $overlay = self::pluralCatalog(self::THREE_FORMS, ['item' => ['E', 'F', 'V']]);
+ $singular = new TranslationEntry(self::MODULE, 'plain');
+ $singular->translate('LOKAL-PLAIN');
+ $overlay->add($singular);
+ $foreign = new TranslationEntry('other', 'item');
+ $foreign->setPlural('item_plural', ['A', 'B', 'C']);
+ $overlay->add($foreign);
+ $before = $overlay->toPoString();
+ $shipped = self::pluralCatalog(self::TWO_FORMS, ['item' => ['E', 'V']]);
+
+ $result = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE);
+
+ $this->assertSame($before, $overlay->toPoString(), 'the overlay passed in is not changed');
+ $this->assertSame('LOKAL-PLAIN', $result->find(self::MODULE, 'plain')?->getTranslation());
+ $this->assertSame(['A', 'B', 'C'], $result->find('other', 'item')?->getPluralTranslations(), 'only the module is rewritten');
+ }
+
+ /**
+ * de -> fr: the new form 0 (n = 0 and 1) covers the two old forms; they differ (one is a local
+ * change), the new form is not the default form - it gets the shipped value, and the local value
+ * that is in no form any more is reported.
+ */
+ public function testWithPluralRuleOfReplacesAnAmbiguousNonDefaultFormByTheShippedValueAndReportsTheLostLocalValue(): void
+ {
+ $overlay = self::pluralCatalog(self::TWO_FORMS, ['item' => ['LOKAL-EIN', 'Éléments']], ['item' => ['Élément', 'Éléments']]);
+ $shipped = self::pluralCatalog(
+ 'nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;',
+ ['item' => ['Élément-fr', 'Éléments-million', 'Éléments-fr']]
+ );
+
+ $result = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over);
+
+ $item = $result->find(self::MODULE, 'item');
+ $this->assertSame(['Élément-fr', 'Éléments', 'Éléments'], $item?->getPluralTranslations());
+ $this->assertSame('Élément-fr', LocalChangeComments::getOriginal($item, 0), 'no local change');
+ $this->assertSame(['item [0]'], $not_taken_over);
+ }
+
+ /**
+ * A message that is not shipped as a plural one any more has no shipped forms to fall back to: a
+ * form without a previous form gets the value of the previous default form, without "original".
+ */
+ public function testWithPluralRuleOfGivesAMessageThatIsNoLongerShippedAsPluralTheValueOfThePreviousDefaultForm(): void
+ {
+ $overlay = self::pluralCatalog(
+ self::THREE_FORMS,
+ ['item' => ['E', 'FEW', 'VIELE']],
+ ['item' => ['E', 'F', 'V']]
+ );
+ $shipped = self::pluralCatalog('nplurals=3; plural=(n == 1 ? 0 : n <= 4 ? 1 : 2);', []);
+ $singular = new TranslationEntry(self::MODULE, 'item');
+ $singular->translate('Singular');
+ $shipped->add($singular);
+
+ $result = MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over);
+
+ $item = $result->find(self::MODULE, 'item');
+ $this->assertSame(['E', 'VIELE', 'VIELE'], $item?->getPluralTranslations());
+ $this->assertNull(LocalChangeComments::getOriginal($item, 1));
+ $this->assertSame('V', LocalChangeComments::getOriginal($item, 2));
+ $this->assertSame(['item [1]'], $not_taken_over, 'the value of the form without a meaning is reported');
+ }
+
+ /**
+ * A header that differs in white space or parentheses only selects the same forms: the overlay is
+ * not rewritten.
+ */
+ public function testWithPluralRuleOfDoesNotRewriteAnOverlayWhoseHeaderOnlyDiffersInSpelling(): void
+ {
+ $overlay = self::pluralCatalog('nplurals=2; plural=n != 1;', ['item' => ['E', 'LOKAL']]);
+ $shipped = self::pluralCatalog('nplurals=2; plural=( n != 1 );', ['item' => ['E', 'V']]);
+
+ $this->assertSame($overlay, MigratedLanguageFileSync::withPluralRuleOf($overlay, $shipped, self::MODULE, $not_taken_over));
+ $this->assertSame([], $not_taken_over);
+ }
+
+ /**
+ * The overlay is written under 3 forms, the shipped `.po` has 2 (update before the next reconciling
+ * write): the form keys are those of the new rule and the local default form is on its default form.
+ */
+ public function testLoadModuleTranslationsServesAnOverlayOfTheOldRuleWithTheFormsOfTheNewRule(): void
+ {
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele']], self::THREE_FORMS);
+ $this->syncLocal(['item [0]' => 'Eintrag', 'item [1]' => 'LOKAL-FEW', 'item [2]' => 'LOKAL-VIELE']);
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS);
+
+ $result = MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame(['item [0]', 'item [1]'], array_keys($result));
+ $this->assertSame('Eintrag', $result['item [0]']['value']);
+ $this->assertFalse($result['item [0]']['local_change']);
+ $this->assertSame('LOKAL-VIELE', $result['item [1]']['value']);
+ $this->assertTrue($result['item [1]']['local_change']);
+ }
+
+ public function testLoadLocalChangesServesAnOverlayOfTheOldRuleWithTheFormsOfTheShippedRule(): void
+ {
+ $this->shipPlural(['item' => ['Eintrag']], self::ONE_FORM);
+ $this->syncLocal(['item [0]' => 'LOKAL']);
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS);
+
+ $result = MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame(['item [0]', 'item [1]'], array_keys($result));
+ $this->assertSame('LOKAL', $result['item [0]']['value']);
+ $this->assertSame('LOKAL', $result['item [1]']['value']);
+ }
+
+ /**
+ * The shipped `.po` is needed for the forms of an overlay with a plural message: if it cannot be
+ * read, the overlay is read with its own rule, as it is written and served - without an error, but
+ * with a warning.
+ */
+ public function testLoadLocalChangesOfAPluralOverlayFallsBackToTheOverlaysRuleIfTheShippedPoCannotBeRead(): void
+ {
+ $this->expectErrorLog();
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele']], self::THREE_FORMS);
+ $this->syncLocal(['item [0]' => 'Eintrag', 'item [1]' => 'Einträge-few', 'item [2]' => 'LOKAL-VIELE']);
+ file_put_contents($this->shippedPo(), "msgid \"kaputt\n");
+
+ $result = MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame(['item [0]', 'item [1]', 'item [2]'], array_keys($result), 'the forms of the overlay rule');
+ $this->assertSame('LOKAL-VIELE', $result['item [2]']['value']);
+ $this->assertStringContainsString('cannot be read', $this->errorLog());
+ }
+
+ /**
+ * Without a plural message the shipped `.po` is not parsed (see
+ * MigratedLanguageFileSyncReadersTest::testLoadLocalChangesNeverParsesTheShippedPoOnlyChecksItExists()) -
+ * also if the overlay has a plural message of another module only.
+ */
+ public function testLoadLocalChangesDoesNotParseTheShippedPoForAnOverlayWithoutAPluralMessageOfTheModule(): void
+ {
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge'], 'plain' => 'X'], self::TWO_FORMS);
+ $this->syncLocal(['plain' => 'LOKAL-PLAIN']);
+ file_put_contents($this->shippedPo(), "msgid \"kaputt\n");
+
+ $result = MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame('LOKAL-PLAIN', $result['plain']['value']);
+ }
+
+ /**
+ * A form key of an import that the rule of the shipped `.po` does not have ("item [2]" with 2
+ * forms) is discarded: no third form, no phantom entry, the valid forms are written.
+ */
+ public function testAFormKeyBeyondTheNumberOfFormsOfTheRuleIsDiscarded(): void
+ {
+ $this->shipPlural(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS);
+
+ $this->syncLocal(['item [0]' => 'Eintrag', 'item [1]' => 'LOKAL', 'item [2]' => 'GEISTER-FORM']);
+
+ $overlay = $this->overlayPo();
+ $this->assertSame(['item'], $this->overlayIdentifiers());
+ $this->assertSame(['Eintrag', 'LOKAL'], $overlay->find(null, 'item')?->getPluralTranslations());
+ $this->assertStringNotContainsString('GEISTER-FORM', (string) file_get_contents($this->overlayBase() . '.po'));
+ }
+
+ /**
+ * The merge into the shipped `.po` of an overlay written under another rule writes the forms by
+ * meaning (the default form of the previous rule to the default form of the shipped one), not by
+ * index.
+ */
+ public function testMergeOfAnOverlayOfTheOldRuleWritesTheFormsByMeaning(): void
+ {
+ $this->shipPlural(['item' => ['Element', 'Elemente-few', 'Elemente-viele']], self::THREE_FORMS);
+ $this->syncLocal(['item [0]' => 'Element', 'item [1]' => 'LOKAL-FEW', 'item [2]' => 'LOKAL-VIELE']);
+ $this->shipPlural(['item' => ['Element', 'Elemente']], self::TWO_FORMS);
+ // below the ILIAS directory like the shipped `.po` (removed with the fixture directory)
+ mkdir($this->fixture_directory . '/backup');
+
+ $result = ShippedPoMerger::merge(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ $this->client_data_dir,
+ $this->fixture_directory . '/backup'
+ );
+
+ $this->assertSame([], $result['skipped']);
+ $this->assertSame([], $result['not_merged']);
+ $this->assertSame([self::MODULE => $this->shippedPo()], $result['written']);
+ $merged = TranslationCatalog::fromPoFile($this->shippedPo())->find(self::MODULE, 'item');
+ $this->assertSame(['Element', 'LOKAL-VIELE'], $merged?->getPluralTranslations());
+ }
+
+ /**
+ * @param array> $entries identifier => forms
+ * @param array> $originals identifier => "original" of every form
+ */
+ private static function pluralCatalog(string $header, array $entries, array $originals = []): TranslationCatalog
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ $catalog->setHeader('Plural-Forms', $header);
+ foreach ($entries as $identifier => $forms) {
+ $entry = new TranslationEntry(self::MODULE, $identifier);
+ $entry->setPlural($identifier . '_plural', $forms);
+ foreach ($originals[$identifier] ?? [] as $form => $original) {
+ LocalChangeComments::setOriginal($entry, $original, $form);
+ }
+ $catalog->add($entry);
+ }
+
+ return $catalog;
+ }
+
+ /**
+ * @param array|string> $entries plain value or the forms of a plural message
+ */
+ private function shipPlural(array $entries, string $header): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ $catalog->setHeader('Plural-Forms', $header);
+ foreach ($entries as $identifier => $value) {
+ $entry = new TranslationEntry(self::MODULE, (string) $identifier);
+ if (is_array($value)) {
+ $entry->setPlural($identifier . '_plural', $value);
+ } else {
+ $entry->translate($value);
+ }
+ $catalog->add($entry);
+ }
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+ }
+
+ /**
+ * @param array $entries identifier or form key => value
+ */
+ private function syncLocal(array $entries): void
+ {
+ $this->sync($entries);
+ }
+
+ /**
+ * What error_log() wrote during this test so far (PHPUnit redirects it to a file per test).
+ */
+ private function errorLog(): string
+ {
+ $file = (string) ini_get('error_log');
+
+ return $file !== '' && is_file($file) ? (string) file_get_contents($file) : '';
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php
new file mode 100644
index 000000000000..37a40fbc1fa0
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php
@@ -0,0 +1,470 @@
+shippedPo(), "msgid \"kaputt\n");
+ $this->seedOverlay(['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]);
+
+ $result = MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame(
+ ['value' => 'Servus', 'local_change' => true, 'local_change_date' => '2020-01-01 00:00:00', 'original' => 'Hallo'],
+ $result['greeting']
+ );
+ }
+
+ public function testLoadLocalChangesReturnsOnlyTheOverlayEntriesNotTheShippedOnes(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->seedOverlay(['greeting' => ['value' => 'Servus', 'original' => 'Hallo']]);
+
+ $this->assertSame(
+ ['greeting' => ['value' => 'Servus', 'local_change' => false, 'local_change_date' => null, 'original' => 'Hallo']],
+ MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir)
+ );
+ }
+
+ public function testLoadLocalChangesReturnsAnEmptyArrayWithoutAnyOverlay(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertSame([], MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ public function testLoadLocalChangesReturnsNullWithoutAShippedPo(): void
+ {
+ $this->seedOverlay(['greeting' => 'Servus']);
+
+ $this->assertNull(MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ public function testLoadLocalChangesReturnsNullForAnUnknownModuleOrWithoutClientDataDir(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertNull(MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', 'other_module', $this->client_data_dir));
+ $this->assertNull(MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, null));
+ }
+
+ /**
+ * An overlay `.mo` without its `.po` (which carries the bookkeeping) must never be treated as "no
+ * overlay" - that would let the next reconciling write remove a local change it cannot actually see.
+ * Every reader that consults the overlay (loadModuleTranslations(), loadLocalChanges()) throws
+ * instead.
+ */
+ public function testAnOverlayMoWithoutItsPoThrowsInsteadOfBeingTreatedAsAbsent(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/has no/');
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ }
+
+ /**
+ * The same guard for loadLocalChanges(), which LanguageInstallationManager's loadOverlay() relies
+ * on to distinguish "no overlay" from "unreadable overlay" (see its own docblock).
+ */
+ public function testLoadLocalChangesThrowsForAnOverlayMoWithoutItsPo(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
+
+ $this->expectException(RuntimeException::class);
+ MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ }
+
+ /**
+ * An irregular file (here: a directory) at the overlay `.po` position must not be silently skipped
+ * either - it is reported instead of being read as "no overlay".
+ */
+ public function testAnIrregularFileAtTheOverlayPoPositionThrows(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ mkdir($this->overlayBase() . '.po', 0775, true);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/no regular file/');
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ }
+
+ /**
+ * sync() itself must refuse to touch such an overlay too - not silently rebuild or remove it (see
+ * the class docblock: "an overlay path that is no regular file, is never replaced or removed").
+ */
+ public function testSyncThrowsForAnOverlayMoWithoutItsPoInsteadOfRemovingOrRebuildingIt(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
+
+ $this->expectException(RuntimeException::class);
+ $this->sync(['greeting' => 'Hallo']);
+ }
+
+ public function testLoadShippedModulesReturnsTheShippedValuesOfEveryMigratedModuleForTheLanguage(): void
+ {
+ $second_directory = MigratedPoFixture::directory('stwo', $this->directory->getPath());
+ $not_migrated = MigratedPoFixture::directory('nomig', $this->directory->getPath());
+ $manager = new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ new MainLanguageFileDirectory(),
+ $this->directory,
+ $second_directory,
+ $not_migrated
+ );
+ $this->seedShipped([
+ 'greeting' => 'Hallo',
+ 'foreign' => ['value' => 'Fremd', 'context' => 'other_module'],
+ ]);
+ $this->seedShipped(['farewell' => 'Tschüss'], 'de', 'stwo');
+ $this->seedShipped(['farewell' => 'Bye'], 'en', 'stwo');
+ $this->seedShipped(['x' => 'only english'], 'en', 'nomig');
+
+ $this->assertSame(
+ [self::MODULE => ['greeting' => 'Hallo'], 'stwo' => ['farewell' => 'Tschüss']],
+ MigratedLanguageFileSync::loadShippedModules($manager, $this->ilias_root, 'de')
+ );
+ }
+
+ public function testLoadShippedModulesReturnsAnEmptyListForAModuleWhoseShippedPoIsEmpty(): void
+ {
+ $this->seedShipped([]);
+
+ $this->assertSame(
+ [self::MODULE => []],
+ MigratedLanguageFileSync::loadShippedModules($this->manager, $this->ilias_root, 'de')
+ );
+ }
+
+ /**
+ * readShippedPo() caches a parsed shipped `.po` per request, keyed by its content hash - so a
+ * `.po` that changes WITHIN the same request (e.g. Setup rewriting it, or - as here - a test
+ * simulating that) is read fresh, never served stale from the cache.
+ */
+ public function testAChangedShippedPoWithinTheSameRequestIsReReadNotServedFromTheCache(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->assertSame(
+ ['greeting' => 'Hallo'],
+ MigratedLanguageFileSync::loadShippedModules($this->manager, $this->ilias_root, 'de')[self::MODULE]
+ );
+
+ $this->seedShipped(['greeting' => 'Servus']);
+
+ $this->assertSame(
+ ['greeting' => 'Servus'],
+ MigratedLanguageFileSync::loadShippedModules($this->manager, $this->ilias_root, 'de')[self::MODULE]
+ );
+ }
+
+ /**
+ * The cache is bounded (SHIPPED_CATALOG_CACHE_SIZE = 512, see the class docblock) - installing all
+ * shipped languages in one request must not let it grow without bound. Direct reflection access to
+ * the private static cache, the only way to observe its size without instrumenting every one of
+ * 513+ parses.
+ */
+ public function testTheShippedPoCacheNeverGrowsBeyondItsConfiguredLimit(): void
+ {
+ $cache = new ReflectionProperty(MigratedLanguageFileSync::class, 'shipped_catalogs');
+ $cache->setValue(null, []);
+
+ try {
+ for ($i = 0; $i < 520; $i++) {
+ $module = 'cachetest' . $i;
+ $directory = MigratedPoFixture::directory($module, $this->directory->getPath());
+ MigratedPoFixture::writePo(
+ $this->fixture_directory . '/' . $module . '_de.po',
+ MigratedPoFixture::catalog($module, ['x' => 'y'])
+ );
+ MigratedLanguageFileSync::loadShippedModuleEntries($this->fixture_directory . '/' . $module . '_de.po', $module);
+ unset($directory);
+ }
+
+ $this->assertLessThanOrEqual(512, count($cache->getValue()));
+ } finally {
+ $cache->setValue(null, []);
+ }
+ }
+
+ /**
+ * Every module with a shipped `.po` for the language - also one whose `.po` cannot be parsed
+ * (the callers need to know it is maintained in PO files to report it) - but neither a module
+ * without shipped `.po` for that language nor the main directory (empty prefix).
+ */
+ public function testFindShippedModuleFilesListsEveryModuleWithAShippedPoForTheLanguage(): void
+ {
+ $corrupt = MigratedPoFixture::directory('scorrupt', $this->directory->getPath());
+ $english_only = MigratedPoFixture::directory('senonly', $this->directory->getPath());
+ $manager = new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ new MainLanguageFileDirectory(),
+ $this->directory,
+ $corrupt,
+ $english_only
+ );
+ $this->seedShipped(['greeting' => 'Hallo']);
+ file_put_contents($this->shippedPo('de', 'scorrupt'), "msgid \"kaputt\n");
+ $this->seedShipped(['x' => 'only english'], 'en', 'senonly');
+
+ $this->assertSame(
+ [self::MODULE => $this->shippedPo(), 'scorrupt' => $this->shippedPo('de', 'scorrupt')],
+ MigratedLanguageFileSync::findShippedModuleFiles($manager, $this->ilias_root, 'de')
+ );
+ }
+
+ public function testHasShippedModulesOnlyForALanguageWithAShippedPo(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertFalse(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, []));
+ $this->assertFalse(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, ['en']));
+ $this->assertTrue(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, ['en', 'de']));
+ }
+
+ /**
+ * The extracted comments ("#.", the counterpart of a .lang "###" comment) of an entry are joined
+ * into one line; an entry without one gets `null`, entries of another context are not returned.
+ */
+ public function testLoadShippedModuleEntriesReturnsValueAndJoinedExtractedComment(): void
+ {
+ $catalog = MigratedPoFixture::catalog(self::MODULE, [
+ 'greeting' => "Hallo\nWelt",
+ 'farewell' => 'Tschüss',
+ 'foreign' => ['value' => 'Fremd', 'context' => 'other_module'],
+ ]);
+ $catalog->find(self::MODULE, 'greeting')->addExtractedComment('shown on the login page');
+ $catalog->find(self::MODULE, 'greeting')->addExtractedComment('keep it short');
+ $catalog->find('other_module', 'foreign')->addExtractedComment('not returned');
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $entries = MigratedLanguageFileSync::loadShippedModuleEntries($this->shippedPo(), self::MODULE);
+ ksort($entries);
+
+ $this->assertSame(
+ [
+ 'farewell' => ['value' => 'Tschüss', 'comment' => null],
+ 'greeting' => ['value' => "Hallo\nWelt", 'comment' => 'shown on the login page keep it short'],
+ ],
+ $entries
+ );
+ }
+
+ /** The own note (translator comment) wins over the reference note ("#."); loadShippedNotes() agrees. */
+ public function testTheTranslatorCommentIsTheNoteAndBothKindsAreShippedComments(): void
+ {
+ $catalog = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hej', 'farewell' => 'Hejdå']);
+ $catalog->find(self::MODULE, 'greeting')->addExtractedComment('en note');
+ $catalog->find(self::MODULE, 'greeting')->addTranslatorNote('egen anteckning');
+ $catalog->find(self::MODULE, 'farewell')->addExtractedComment('en note');
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $entries = MigratedLanguageFileSync::loadShippedModuleEntries($this->shippedPo(), self::MODULE);
+
+ $this->assertSame('egen anteckning', $entries['greeting']['comment']);
+ $this->assertSame('en note', $entries['farewell']['comment']);
+ $this->assertSame(
+ ['greeting' => ['egen anteckning'], 'farewell' => ['en note']],
+ MigratedLanguageFileSync::loadShippedNotes($this->shippedPo(), self::MODULE)
+ );
+ }
+
+ public function testLoadShippedModuleEntriesThrowsForACorruptPo(): void
+ {
+ file_put_contents($this->shippedPo(), "msgid \"kaputt\n");
+
+ $this->expectException(RuntimeException::class);
+ MigratedLanguageFileSync::loadShippedModuleEntries($this->shippedPo(), self::MODULE);
+ }
+
+ public function testLoadModuleTranslationsReturnsValueLocalChangeDateAndOriginalPerEntry(): void
+ {
+ $this->seedShipped(['changed' => 'Hallo']);
+ $this->seedOverlay([
+ 'changed' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2026-09-21T10:11:12Z'],
+ 'unchanged' => ['value' => 'Tschüss', 'original' => 'Tschüss'],
+ 'foreign' => ['value' => 'x', 'context' => 'other_module'],
+ ]);
+
+ $this->assertSame(
+ [
+ 'changed' => [
+ 'value' => 'Servus',
+ 'local_change' => true,
+ 'local_change_date' => '2026-09-21 10:11:12',
+ 'original' => 'Hallo',
+ ],
+ 'unchanged' => [
+ 'value' => 'Tschüss',
+ 'local_change' => false,
+ 'local_change_date' => null,
+ 'original' => 'Tschüss',
+ ],
+ ],
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)
+ );
+ }
+
+ /** Only the overlay `.po` is read: a missing overlay `.mo` does not make the module "not migrated". */
+ public function testLoadModuleTranslationsIgnoresAMissingOverlayMoAndStillReadsTheOverlayPo(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]);
+ unlink(MigratedPoFixture::overlayMo($this->overlayBase()));
+
+ $this->assertSame(
+ ['value' => 'Servus', 'local_change' => true, 'local_change_date' => '2020-01-01 00:00:00', 'original' => 'Hallo'],
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['greeting']
+ );
+ }
+
+ /**
+ * Without an overlay `.po` at all, loadModuleTranslations() falls back to the shipped-only result
+ * (never `null`, as long as the module is migrated - i.e. has a shipped `.po`) - a lone `.mo`
+ * (which sync() itself never produces, but a stray file could) changes nothing either, since only
+ * the `.po` is ever consulted.
+ */
+ /**
+ * An overlay `.mo` without its `.po` may hold a local change ilLanguage serves: treating it as "no overlay" would
+ * let the next write remove it. It is reported as unreadable (callers fall back to the database), and a write
+ * leaves it untouched.
+ */
+ public function testAnOverlayMoWithoutItsPoIsUnreadableAndNotRemovedByAWrite(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Stray']));
+
+ try {
+ MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('has no', $e->getMessage());
+ }
+ try {
+ $this->sync(['greeting' => 'Hallo']);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException) {
+ }
+
+ $this->assertSame(['greeting' => 'Stray'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ public function testGetMigratedModulesListsAModuleByItsShippedPoAloneIndependentOfAnyOverlay(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Servus']);
+
+ $this->assertSame([self::MODULE], MigratedLanguageFileSync::getMigratedModules($this->manager, 'de', $this->client_data_dir));
+ $this->assertSame([], MigratedLanguageFileSync::getMigratedModules($this->manager, 'en', $this->client_data_dir));
+ $this->assertSame([], MigratedLanguageFileSync::getMigratedModules($this->manager, 'de', null));
+ }
+
+ /**
+ * The essence of "migrated == has a shipped .po": a module without ANY overlay file at all (never
+ * had a local change) is still listed as migrated for its shipped language.
+ */
+ public function testGetMigratedModulesListsAModuleWithNoOverlayFileWhatsoever(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertDirectoryDoesNotExist(dirname($this->overlayBase()));
+ $this->assertSame([self::MODULE], MigratedLanguageFileSync::getMigratedModules($this->manager, 'de', $this->client_data_dir));
+ }
+
+ public function testRemoveOverlayDeletesBothOverlayFilesButLeavesTheShippedPoUntouched(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->seedOverlay(['greeting' => 'Hallo']);
+ $shipped_before = file_get_contents($this->shippedPo());
+
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po');
+ $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()));
+ $this->assertSame($shipped_before, file_get_contents($this->shippedPo()));
+ }
+
+ public function testRemoveOverlayOnlyRemovesTheRequestedLanguage(): void
+ {
+ $this->seedOverlay(['greeting' => 'Hallo'], 'de');
+ $this->seedOverlay(['greeting' => 'Hello'], 'en');
+
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertFileExists($this->overlayBase('en') . '.po');
+ $this->assertFileExists(MigratedPoFixture::overlayMo($this->overlayBase('en')));
+ }
+
+ public function testRemoveOverlayIsANoOpWithoutOverlayUnknownModuleOrClientDataDir(): void
+ {
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->seedOverlay(['greeting' => 'Hallo']);
+
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', 'other_module', $this->client_data_dir);
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, null);
+
+ $this->assertFileExists($this->overlayBase() . '.po');
+ $this->assertFileExists(MigratedPoFixture::overlayMo($this->overlayBase()));
+ }
+
+ public function testRemoveOverlayThrowsWhenAnOverlayFileCannotBeRemoved(): void
+ {
+ if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
+ $this->markTestSkipped('A non-removable file cannot be forced for root (no root-proof variant for unlink()).');
+ }
+ $this->seedOverlay(['greeting' => 'Hallo']);
+ $overlay_directory = dirname($this->overlayBase());
+ chmod($overlay_directory, 0555);
+
+ // the lock file cannot be created in the read-only directory either - logged (error_log)
+ $this->expectErrorLog();
+ set_error_handler(static fn(): bool => true, E_WARNING);
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/overlay file/i');
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ } finally {
+ restore_error_handler();
+ chmod($overlay_directory, 0775);
+ }
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRemarksTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRemarksTest.php
new file mode 100644
index 000000000000..ffec8d2cdd6b
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRemarksTest.php
@@ -0,0 +1,366 @@
+fixture_directory = __DIR__ . '/tmp-remarks-sync-fixtures-' . bin2hex(random_bytes(4));
+ mkdir($this->fixture_directory, 0775, true);
+ $this->client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_remarks_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+
+ $this->directory = MigratedPoFixture::directory(
+ self::MODULE,
+ 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/'
+ );
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->fixture_directory);
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+
+ parent::tearDown();
+ }
+
+ private function shippedPo(string $lang_key = 'de'): string
+ {
+ return $this->fixture_directory . '/' . self::MODULE . '_' . $lang_key . '.po';
+ }
+
+ private function overlayBase(string $lang_key = 'de'): string
+ {
+ return MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, $lang_key);
+ }
+
+ private function overlayPo(): TranslationCatalog
+ {
+ return MigratedPoFixture::readPo($this->overlayBase() . '.po');
+ }
+
+ private function overlayEntry(string $identifier): ?TranslationEntry
+ {
+ return $this->overlayPo()->find(null, $identifier);
+ }
+
+ /**
+ * @param array> $entries plain value, or the forms of a plural
+ * message (msgstr[0], msgstr[1], ...) of the identifier
+ */
+ private function seedShipped(array $entries, string $header = 'nplurals=2; plural=(n != 1);'): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ $catalog->setHeader('Plural-Forms', $header);
+ foreach ($entries as $identifier => $value) {
+ $entry = new TranslationEntry(self::MODULE, (string) $identifier);
+ if (is_array($value)) {
+ $entry->setPlural($identifier . '_plural', $value);
+ } else {
+ $entry->translate($value);
+ }
+ $catalog->add($entry);
+ }
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+ }
+
+ /**
+ * @param array $entries identifier => value
+ * @param array|null $remarks identifier => remark, `null` keeps the overlay's own
+ */
+ private function sync(array $entries, ?array $remarks = null): void
+ {
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ ILIAS_ABSOLUTE_PATH,
+ 'de',
+ self::MODULE,
+ $entries,
+ $this->client_data_dir,
+ false,
+ null,
+ $remarks
+ );
+ }
+
+ /**
+ * A remark on a key whose value is exactly the shipped one still belongs in the overlay - as a
+ * "remark only" entry with an empty msgstr in the `.po`, absent from readMoTranslations()'s
+ * result (the compiled `.mo` leaves it out entirely, so nothing served changes).
+ */
+ public function testARemarkOnAnUnchangedValueCreatesARemarkOnlyEntryAbsentFromTheCompiledMo(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+
+ $entry = $this->overlayEntry('greeting');
+ $this->assertNotNull($entry, 'the remark-only entry must be in the overlay .po');
+ $this->assertSame('', $entry->getTranslation(), 'empty msgstr - the value itself is unchanged');
+ $this->assertSame('Bitte prüfen', LocalChangeComments::getRemark($entry));
+ $this->assertArrayNotHasKey('greeting', MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * loadModuleTranslations() (what ilLanguage ultimately reads) and loadLocalChanges() (the admin
+ * GUI's per-key overlay state) both skip a remark-only entry entirely - it carries no value.
+ */
+ public function testARemarkOnlyEntryIsSkippedByLoadModuleTranslationsAndLoadLocalChanges(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+
+ $translations = MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->assertSame('Hallo', $translations['greeting']['value'] ?? null);
+ $this->assertFalse($translations['greeting']['local_change']);
+
+ $local_changes = MigratedLanguageFileSync::loadLocalChanges($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->assertArrayNotHasKey('greeting', $local_changes, 'a remark-only entry is not a local change of the value');
+ }
+
+ public function testLoadRemarksReadsTheRemarkOfARemarkOnlyEntry(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+
+ $remarks = MigratedLanguageFileSync::loadRemarks($this->manager, 'de', self::MODULE, $this->client_data_dir);
+
+ $this->assertSame(['greeting' => 'Bitte prüfen'], $remarks);
+ }
+
+ public function testLoadRemarksIsEmptyWithoutAnyOverlayAtAll(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertSame([], MigratedLanguageFileSync::loadRemarks($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ /**
+ * $remarks = null keeps the overlay's existing remarks: a value-only sync() (ordinary GUI save,
+ * writePluralRows()/sync() of ilObjLanguageExt) must not wipe a remark.
+ */
+ public function testNullRemarksKeepsTheExistingOverlayRemark(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+
+ $this->sync(['greeting' => 'Servus']); // remarks omitted (null) - only the value changes
+
+ $entry = $this->overlayEntry('greeting');
+ $this->assertNotNull($entry);
+ $this->assertSame('Servus', $entry->getTranslation());
+ $this->assertSame('Bitte prüfen', LocalChangeComments::getRemark($entry), 'the remark must survive a remarks-less sync()');
+ }
+
+ /**
+ * $remarks = [] removes every remark of the module - and, once the value is back to shipped too,
+ * the overlay disappears entirely (nothing left to keep).
+ */
+ public function testEmptyArrayRemarksRemovesEveryRemarkAndDropsTheOverlayIfNothingElseIsLeft(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+ $this->assertNotNull($this->overlayEntry('greeting'), 'precondition');
+
+ $this->sync(['greeting' => 'Hallo'], []);
+
+ $this->assertFalse(is_file($this->overlayBase() . '.po'), 'nothing left to keep - the whole overlay is removed');
+ }
+
+ /**
+ * A later real value change on the very same key keeps its remark - the "remark only" entry is
+ * simply replaced by an ordinary one carrying both the new value and the remark.
+ */
+ public function testARealValueChangeOnTheSameKeyKeepsItsRemark(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Bitte prüfen']);
+
+ $this->sync(['greeting' => 'Servus'], ['greeting' => 'Bitte prüfen']);
+
+ $entry = $this->overlayEntry('greeting');
+ $this->assertNotNull($entry);
+ $this->assertSame('Servus', $entry->getTranslation());
+ $this->assertSame('Bitte prüfen', LocalChangeComments::getRemark($entry));
+ $this->assertSame('Servus', MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase()))['greeting'] ?? null);
+ }
+
+ /**
+ * A plural message's remark belongs to its base identifier (never a form key) - even when only
+ * the remark, not any form's value, changed.
+ */
+ public function testAPluralMessagesRemarkBelongsToItsBaseIdentifier(): void
+ {
+ $this->seedShipped(['item' => ['Eintrag', 'Einträge']]);
+
+ $this->sync(
+ ['item [0]' => 'Eintrag', 'item [1]' => 'Einträge'],
+ ['item' => 'Bitte prüfen']
+ );
+
+ $entry = $this->overlayEntry('item');
+ $this->assertNotNull($entry);
+ $this->assertTrue($entry->isPlural());
+ $this->assertSame(['', ''], $entry->getPluralTranslations(), 'remark only - both forms stay empty');
+ $this->assertSame('Bitte prüfen', LocalChangeComments::getRemark($entry));
+ $this->assertArrayNotHasKey('item', MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
+ }
+
+ /**
+ * setRemarks() changes exactly the given identifiers' remarks under the overlay lock, leaving
+ * every value and every other remark untouched.
+ */
+ public function testSetRemarksChangesOnlyTheGivenIdentifiersRemarks(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+ $this->sync(['greeting' => 'Hallo', 'farewell' => 'Tschüss'], ['greeting' => 'Alte Bemerkung']);
+
+ MigratedLanguageFileSync::setRemarks(
+ $this->manager,
+ 'de',
+ self::MODULE,
+ ['greeting' => 'Neue Bemerkung', 'farewell' => 'Auch geprüft'],
+ $this->client_data_dir
+ );
+
+ $remarks = MigratedLanguageFileSync::loadRemarks($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ // order is not a guaranteed contract of loadRemarks() - only the content is
+ ksort($remarks);
+ $this->assertSame(['farewell' => 'Auch geprüft', 'greeting' => 'Neue Bemerkung'], $remarks);
+ }
+
+ public function testSetRemarksWithNullOrEmptyStringRemovesTheRemark(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $this->sync(['greeting' => 'Hallo'], ['greeting' => 'Alte Bemerkung']);
+
+ MigratedLanguageFileSync::setRemarks($this->manager, 'de', self::MODULE, ['greeting' => null], $this->client_data_dir);
+
+ $this->assertSame([], MigratedLanguageFileSync::loadRemarks($this->manager, 'de', self::MODULE, $this->client_data_dir));
+ }
+
+ /**
+ * A remark that is not valid UTF-8 (e.g. stale lng_data content from before remarks were
+ * validated) is dropped with a warning instead of making sync() throw or the overlay unreadable -
+ * every other entry (here: a second, valid remark) is still written.
+ */
+ public function testSyncDropsAnInvalidUtf8RemarkButWritesEveryOtherEntry(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
+
+ // no logger is registered in this test process - the warning falls back to error_log(),
+ // which would otherwise print to this test's own output and mark it risky
+ $previous_error_log = ini_set('error_log', sys_get_temp_dir() . '/ilias_mlfs_remarks_test_error.log');
+ try {
+ $this->sync(
+ ['greeting' => 'Hallo', 'farewell' => 'Tschüss'],
+ ['greeting' => "Ung\xFCltig", 'farewell' => 'Gültige Bemerkung']
+ );
+ } finally {
+ ini_set('error_log', (string) $previous_error_log);
+ }
+
+ $remarks = MigratedLanguageFileSync::loadRemarks($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->assertArrayNotHasKey('greeting', $remarks);
+ $this->assertSame('Gültige Bemerkung', $remarks['farewell'] ?? null);
+ }
+
+ /**
+ * A safety net independent of the remark check above: if the compiled overlay content would not
+ * be valid UTF-8 for any other reason (here: an invalid value, not a remark), sync() throws
+ * instead of writing an unreadable file - fromPoFile() would refuse to read it back, losing every
+ * local change of the module.
+ */
+ public function testSyncThrowsInsteadOfWritingAnOverlayThatWouldNotBeValidUtf8(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/would not be valid UTF-8/');
+ $this->sync(['greeting' => "Ung\xFCltig"]);
+ }
+
+ /**
+ * Only entries actually flagged "fuzzy" in the shipped `.po` are listed - a plural message's every
+ * form key, a singular message its plain identifier.
+ */
+ public function testLoadShippedFuzzyIdentifiersListsFuzzyEntriesOnly(): void
+ {
+ $catalog = new TranslationCatalog();
+ $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $fuzzy_singular = new TranslationEntry(self::MODULE, 'fuzzy_one');
+ $fuzzy_singular->translate('Noch nicht übersetzt');
+ $fuzzy_singular->addFlag('fuzzy');
+ $catalog->add($fuzzy_singular);
+ $clean_singular = new TranslationEntry(self::MODULE, 'clean');
+ $clean_singular->translate('Übersetzt');
+ $catalog->add($clean_singular);
+ $fuzzy_plural = new TranslationEntry(self::MODULE, 'item');
+ $fuzzy_plural->setPlural('items', ['Eintrag', 'Einträge']);
+ $fuzzy_plural->addFlag('fuzzy');
+ $catalog->add($fuzzy_plural);
+ MigratedPoFixture::writePo($this->shippedPo(), $catalog);
+
+ $fuzzy = MigratedLanguageFileSync::loadShippedFuzzyIdentifiers($this->shippedPo(), self::MODULE);
+
+ $this->assertSame(
+ ['fuzzy_one' => true, 'item [0]' => true, 'item [1]' => true],
+ $fuzzy
+ );
+ }
+
+ public function testLoadShippedFuzzyIdentifiersIsEmptyWithoutAnyFuzzyEntry(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertSame([], MigratedLanguageFileSync::loadShippedFuzzyIdentifiers($this->shippedPo(), self::MODULE));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRevisionTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRevisionTest.php
new file mode 100644
index 000000000000..357c6b21d236
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRevisionTest.php
@@ -0,0 +1,218 @@
+/lang///r-/`, named by `current`)
+ * as MigratedLanguageFileSync::sync() manages them: a new revision is served right away by the
+ * running process (native gettext never reloads a catalog, so every state has a directory of its
+ * own), the previous one is kept for requests still reading it, nothing older stays, and an overlay
+ * that became empty serves nothing any more.
+ *
+ * Everything lives below temporary directories (the shipped `.po` below the "ILIAS root" $root).
+ */
+class MigratedLanguageFileSyncRevisionTest extends TestCase
+{
+ private const string MODULE = 'rvs';
+
+ private string $root;
+ private string $client_data_dir;
+ private LanguageFileDirectoryManager $manager;
+
+ protected function setUp(): void
+ {
+ MigratedPoFixture::resetRuntime();
+ $this->root = sys_get_temp_dir() . '/ilias_overlay_revisions_' . bin2hex(random_bytes(6));
+ $this->client_data_dir = $this->root . '/client';
+ mkdir($this->client_data_dir, 0775, true);
+
+ MigratedPoFixture::writePo(
+ $this->root . '/rvs/rvs_de.po',
+ MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo', 'other' => 'Anders'])
+ );
+ $this->manager = new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ MigratedPoFixture::directory(self::MODULE, 'rvs/')
+ );
+ MigratedPoFixture::build($this->manager, $this->root);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->root);
+ MigratedPoFixture::resetRuntime();
+ }
+
+ /**
+ * @param array $entries
+ */
+ private function sync(array $entries): void
+ {
+ MigratedLanguageFileSync::sync($this->manager, $this->root, 'de', self::MODULE, $entries, $this->client_data_dir);
+ }
+
+ private function overlayDirectory(): string
+ {
+ return MigratedLanguageFilePaths::overlayDirectory($this->client_data_dir, self::MODULE, 'de');
+ }
+
+ private function served(string $key = 'greeting'): ?string
+ {
+ return (new TranslationGettextRepository())->getText(self::MODULE, 'de', $key, $this->client_data_dir);
+ }
+
+ /**
+ * @return list the revision directories of the overlay, sorted
+ */
+ private function revisions(): array
+ {
+ $revisions = array_values(array_filter(
+ scandir($this->overlayDirectory()) ?: [],
+ static fn(string $name): bool => MigratedLanguageFilePaths::isOverlayRevision($name)
+ ));
+ sort($revisions);
+
+ return $revisions;
+ }
+
+ private function current(): ?string
+ {
+ $file = MigratedLanguageFilePaths::overlayCurrentFile($this->overlayDirectory());
+
+ return is_file($file) ? trim((string) file_get_contents($file)) : null;
+ }
+
+ public function testEveryNewRevisionIsServedAtOnceAndOnlyTheCurrentAndThePreviousOneStay(): void
+ {
+ $previous = null;
+ foreach (['Servus', 'Moin', 'Grüß Gott', 'Servus', 'Tach'] as $value) {
+ $this->sync(['greeting' => $value]);
+
+ $current = $this->current();
+ $this->assertNotNull($current);
+ $this->assertNotSame($previous, $current);
+ $this->assertSame($value, $this->served(), 'visible in the process that wrote it');
+ $this->assertSame('Anders', $this->served('other'), 'the shipped values stay served');
+ $expected = array_values(array_filter([$current, $previous]));
+ sort($expected);
+ $this->assertSame($expected, $this->revisions(), 'the current revision and the one served before it - nothing older');
+ $previous = $current;
+ }
+ }
+
+ public function testSyncingTheSameContentAgainWritesNothingNew(): void
+ {
+ $this->sync(['greeting' => 'Servus']);
+ $current = $this->current();
+ $current_file = MigratedLanguageFilePaths::overlayCurrentFile($this->overlayDirectory());
+ $inode = fileinode($current_file);
+ clearstatcache();
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame($current, $this->current());
+ $this->assertSame([$current], $this->revisions());
+ $this->assertSame($inode, fileinode($current_file), '`current` was not written again');
+ }
+
+ public function testGoingBackToTheContentOfTheKeptPreviousRevisionReusesIt(): void
+ {
+ $this->sync(['greeting' => 'Servus']);
+ $first = (string) $this->current();
+ $this->sync(['greeting' => 'Moin']);
+ $second = (string) $this->current();
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame($first, $this->current());
+ $this->assertSame('Servus', $this->served());
+ $expected = [$first, $second];
+ sort($expected);
+ $this->assertSame($expected, $this->revisions());
+ }
+
+ public function testADamagedRevisionIsNeverReusedAndNeverChangedInPlace(): void
+ {
+ $this->sync(['greeting' => 'Servus']);
+ $damaged = (string) $this->current();
+ $this->sync(['greeting' => 'Moin']);
+ $kept = (string) $this->current();
+ // a running process may have loaded that revision: it is not repaired, a new one is written
+ $mo = MigratedLanguageFilePaths::overlayMoFile(
+ MigratedLanguageFilePaths::overlayRevisionDirectory($this->overlayDirectory(), $damaged),
+ self::MODULE,
+ 'de'
+ );
+ file_put_contents($mo, 'garbage');
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $current = (string) $this->current();
+ $this->assertNotSame($damaged, $current);
+ $this->assertSame('Servus', $this->served());
+ $this->assertSame(
+ ['greeting' => 'Servus'],
+ MigratedPoFixture::readMo(MigratedLanguageFilePaths::overlayMoFile(
+ MigratedLanguageFilePaths::overlayRevisionDirectory($this->overlayDirectory(), $current),
+ self::MODULE,
+ 'de'
+ ))
+ );
+ $expected = [$current, $kept];
+ sort($expected);
+ $this->assertSame($expected, $this->revisions(), 'the damaged one is gone, the previous one is kept');
+ }
+
+ public function testAnOverlayThatBecameEmptyServesTheShippedValuesAgainAndTheNextSyncRemovesItsRevisions(): void
+ {
+ $this->sync(['greeting' => 'Servus']);
+ $this->sync(['greeting' => 'Moin']);
+ $overlay_po = $this->overlayDirectory() . '/rvs_de.po';
+ $this->assertFileExists($overlay_po);
+
+ $this->sync(['greeting' => 'Hallo', 'other' => 'Anders']);
+
+ $this->assertFileDoesNotExist($overlay_po);
+ $this->assertNull($this->current(), 'nothing is named any more, so nothing of the old revisions is served');
+ $this->assertSame('Hallo', $this->served());
+ $this->assertFileExists(MigratedLanguageFilePaths::overlayLockFile($this->overlayDirectory()), 'the lock file is kept');
+
+ $this->sync(['greeting' => 'Hallo', 'other' => 'Anders']);
+
+ $this->assertSame([], $this->revisions());
+ }
+
+ public function testRevisionsWithoutAnOverlayAreRemovedByTheNextSyncOfShippedValues(): void
+ {
+ $this->sync(['greeting' => 'Servus']);
+ unlink($this->overlayDirectory() . '/rvs_de.po');
+ unlink(MigratedLanguageFilePaths::overlayCurrentFile($this->overlayDirectory()));
+ $this->assertCount(1, $this->revisions(), 'precondition: what a crash or an old version leaves behind');
+
+ $this->sync(['greeting' => 'Hallo', 'other' => 'Anders']);
+
+ $this->assertSame([], $this->revisions());
+ $this->assertSame('Hallo', $this->served());
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncSymlinkTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncSymlinkTest.php
new file mode 100644
index 000000000000..ed608c058f8d
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncSymlinkTest.php
@@ -0,0 +1,287 @@
+/lang` and above) is exempt, being the administrator's configuration.
+ *
+ * Each scenario also proves the decoy the link points to stays untouched - a RuntimeException alone would miss a check-
+ * then-follow mutation.
+ */
+class MigratedLanguageFileSyncSymlinkTest extends TestCase
+{
+ private const string MODULE = 'stest';
+
+ private string $fixture_directory;
+ private string $client_data_dir;
+ private string $decoy_directory;
+ private LanguageFileDirectory $directory;
+ private LanguageFileDirectoryManager $manager;
+
+ protected function setUp(): void
+ {
+ // never the build of the installation, see MigratedPoFixture::resetRuntime()
+ MigratedPoFixture::resetRuntime();
+ parent::setUp();
+
+ if (!defined('ILIAS_ABSOLUTE_PATH')) {
+ define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../../'));
+ }
+
+ $this->fixture_directory = __DIR__ . '/tmp-symlink-fixtures-' . bin2hex(random_bytes(4));
+ mkdir($this->fixture_directory, 0775, true);
+ $this->client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_symlink_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+ $this->decoy_directory = sys_get_temp_dir() . '/ilias_mlfs_symlink_decoy_' . bin2hex(random_bytes(4));
+ mkdir($this->decoy_directory, 0775, true);
+
+ // an extra "sub" segment below the fixture directory, so an intermediate directory can be replaced by a symlink
+ // without touching the fixture/client data directories
+ $this->directory = MigratedPoFixture::directory(
+ self::MODULE,
+ 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/sub/'
+ );
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->fixture_directory);
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+ MigratedPoFixture::removeDirectory($this->decoy_directory);
+
+ parent::tearDown();
+ }
+
+ private function seedShipped(string $value = 'Hallo'): void
+ {
+ MigratedPoFixture::writePo(
+ $this->shippedPo(),
+ MigratedPoFixture::catalog(self::MODULE, ['greeting' => $value])
+ );
+ }
+
+ private function shippedPo(): string
+ {
+ return $this->fixture_directory . '/sub/' . self::MODULE . '_de.po';
+ }
+
+ private function overlayBase(): string
+ {
+ return MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, 'de');
+ }
+
+ private function lockFile(): string
+ {
+ return MigratedPoFixture::overlayLock($this->overlayBase());
+ }
+
+ private function sync(array $entries = ['greeting' => 'Hallo']): void
+ {
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ ILIAS_ABSOLUTE_PATH,
+ 'de',
+ self::MODULE,
+ $entries,
+ $this->client_data_dir
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function decoyListing(): array
+ {
+ return array_values(array_diff(scandir($this->decoy_directory) ?: [], ['.', '..']));
+ }
+
+ /**
+ * Mutation: assertNoSymbolicLinkBelowOverlayRoot() not being called (or not checking every
+ * intermediate component) for the `.po` path before it is written.
+ */
+ public function testASymlinkedIntermediateDirectoryIsRejectedAndItsTargetIsLeftUntouched(): void
+ {
+ $this->seedShipped();
+ mkdir(dirname(dirname($this->overlayBase())), 0775, true);
+ symlink($this->decoy_directory, dirname($this->overlayBase()));
+
+ try {
+ // a local change: a shipped value alone writes nothing (delta overlay)
+ $this->sync(['greeting' => 'Servus']);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('symbolic link', $e->getMessage());
+ }
+
+ $this->assertSame([], $this->decoyListing(), 'nothing may be written into the linked-to directory');
+ $this->assertSame($this->decoy_directory, readlink(dirname($this->overlayBase())), 'the symlink itself must be left in place');
+ }
+
+ /**
+ * Mutation: the `assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_po)` call being
+ * removed - a `.po` path that is itself a symlink must not be replaced via that link.
+ */
+ public function testASymlinkedOverlayPoIsRejectedAndItsTargetIsLeftUntouched(): void
+ {
+ $this->seedShipped();
+ mkdir(dirname($this->overlayBase()), 0775, true);
+ $decoy = $this->decoy_directory . '/decoy.po';
+ file_put_contents($decoy, 'DECOY');
+ symlink($decoy, $this->overlayBase() . '.po');
+
+ try {
+ $this->sync();
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('symbolic link', $e->getMessage());
+ }
+
+ $this->assertSame('DECOY', file_get_contents($decoy), 'the file the link points to must not be modified');
+ $this->assertSame($decoy, readlink($this->overlayBase() . '.po'), 'the symlink itself must not be replaced');
+ }
+
+ /**
+ * Mutation: the `assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_mo)` call being
+ * removed - exercised with a missing `.po` (a fresh overlay) so this is specifically the `.mo`
+ * check, not the `.po` one, that rejects the write.
+ */
+ public function testASymlinkedOverlayCurrentFileIsRejectedAndItsTargetIsLeftUntouched(): void
+ {
+ $this->seedShipped();
+ mkdir(dirname($this->overlayBase()), 0775, true);
+ $decoy = $this->decoy_directory . '/decoy.mo';
+ file_put_contents($decoy, 'DECOY');
+ symlink($decoy, MigratedPoFixture::overlayCurrent($this->overlayBase()));
+
+ try {
+ $this->sync();
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('symbolic link', $e->getMessage());
+ }
+
+ $this->assertSame('DECOY', file_get_contents($decoy));
+ $this->assertSame($decoy, readlink(MigratedPoFixture::overlayCurrent($this->overlayBase())));
+ $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'the .po must not have been written either');
+ }
+
+ /**
+ * A symlinked lock file is no RuntimeException from sync(): acquireLock() catches it and runs $callback WITHOUT a
+ * lock, like a lock file that cannot be opened. The symlink is never followed and the overlay write still succeeds.
+ *
+ * Mutation: acquireLock() calling fopen()/flock() on the lock path without checking it for a symlink first.
+ */
+ public function testASymlinkedLockFileIsNeverFollowedAndTheOverlayIsStillWrittenUnlocked(): void
+ {
+ $this->seedShipped();
+ mkdir(dirname($this->lockFile()), 0775, true);
+ $decoy = $this->decoy_directory . '/decoy.lock';
+ file_put_contents($decoy, 'DECOY');
+ symlink($decoy, $this->lockFile());
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame('DECOY', file_get_contents($decoy), 'the lock file link target must never be opened/locked');
+ $this->assertSame($decoy, readlink($this->lockFile()), 'the symlink itself must not be replaced');
+ $this->assertSame(
+ ['greeting' => 'Servus'],
+ MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())),
+ 'the overlay write itself must still succeed even though no lock could be taken'
+ );
+ }
+
+ /**
+ * Mutation: removeOverlayFiles() unlinking `$base . '.po'` without first asserting it is not a
+ * symlink.
+ */
+ public function testRemoveOverlayRejectsASymlinkedPoAndLeavesItsTargetAndTheMoUntouched(): void
+ {
+ $overlay_dir = dirname($this->overlayBase());
+ mkdir($overlay_dir, 0775, true);
+ MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
+ file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), 'REAL MO');
+ $decoy = $this->decoy_directory . '/decoy.po';
+ file_put_contents($decoy, 'DECOY');
+ symlink($decoy, $this->overlayBase() . '.po');
+
+ try {
+ MigratedLanguageFileSync::removeOverlay($this->manager, 'de', self::MODULE, $this->client_data_dir);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('symbolic link', $e->getMessage());
+ }
+
+ $this->assertSame('DECOY', file_get_contents($decoy));
+ $this->assertSame($decoy, readlink($this->overlayBase() . '.po'), 'the symlink must not have been unlinked');
+ $this->assertSame('REAL MO', file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo must not have been removed either');
+ }
+
+ /**
+ * The overlay root itself ("/lang") may be a symlink (e.g. a mounted data volume); only what is
+ * BELOW it is protected.
+ */
+ public function testASymlinkOnTheOverlayRootItselfIsAllowed(): void
+ {
+ $this->seedShipped();
+ $real_lang_dir = $this->client_data_dir . '-real-lang';
+ mkdir($real_lang_dir, 0775, true);
+ symlink($real_lang_dir, $this->client_data_dir . '/lang');
+
+ $this->sync(['greeting' => 'Servus']);
+
+ $this->assertSame(
+ ['greeting' => 'Servus'],
+ MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())),
+ 'the write must go through the symlinked root into the real directory'
+ );
+
+ MigratedPoFixture::removeDirectory($real_lang_dir);
+ }
+
+ /**
+ * Mutation: AtomicFileWriter::write() not enforcing $confine_to_directory - a target outside of
+ * it (not necessarily via a symlink; a plain path escape is enough) must be rejected before
+ * anything is written.
+ */
+ public function testAtomicFileWriterRejectsATargetOutsideTheConfinedDirectory(): void
+ {
+ $outside = $this->decoy_directory . '/outside.po';
+
+ try {
+ AtomicFileWriter::write($outside, 'should not be written', $this->client_data_dir);
+ $this->fail('Expected a RuntimeException');
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('does not resolve to a path below', $e->getMessage());
+ }
+
+ $this->assertFileDoesNotExist($outside);
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTakenOverTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTakenOverTest.php
new file mode 100644
index 000000000000..0e2c3211705f
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTakenOverTest.php
@@ -0,0 +1,107 @@
+/lang/taken-over..json`.
+ */
+class MigratedLanguageFileSyncTakenOverTest extends MigratedLanguageFileSyncTestCase
+{
+ private function recordFile(string $lang_key = 'de'): string
+ {
+ return MigratedLanguageFilePaths::takenOverFile($this->client_data_dir, $lang_key);
+ }
+
+ /**
+ * @return list
+ */
+ private function recorded(string $lang_key = 'de'): array
+ {
+ return MigratedLanguageFileSync::modulesWithLocalChangesTakenOver($this->client_data_dir, $lang_key);
+ }
+
+ public function testMarkingMergesSortsAndDeduplicatesPerLanguage(): void
+ {
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['pilot', 'alpha']);
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['pilot', 'beta']);
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'fr', ['gamma']);
+
+ $this->assertSame(['alpha', 'beta', 'pilot'], $this->recorded());
+ $this->assertSame(['gamma'], $this->recorded('fr'));
+ $this->assertSame('["alpha","beta","pilot"]' . "\n", file_get_contents($this->recordFile()));
+ }
+
+ /** The read-modify-write runs under `.locks/taken-over..lock`; without a usable lock directory it runs unlocked. */
+ public function testMarkingTakesTheLockAndStillWorksWhereNoLockCanBeTaken(): void
+ {
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['alpha']);
+ $this->assertFileExists(MigratedLanguageFilePaths::takenOverLockFile($this->client_data_dir, 'de'));
+
+ $elsewhere = $this->client_data_dir . '/elsewhere';
+ mkdir($elsewhere);
+ rename($this->client_data_dir . '/lang/.locks', $this->client_data_dir . '/lang/.locks-moved');
+ symlink($elsewhere, $this->client_data_dir . '/lang/.locks');
+
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['beta']);
+
+ $this->assertSame(['alpha', 'beta'], $this->recorded());
+ $this->assertSame([], glob($elsewhere . '/*'), 'no lock file was created through the link');
+ }
+
+ public function testNothingIsRecordedWithoutAClientDataDirectoryAndForAnInvalidLanguage(): void
+ {
+ $this->assertSame([], MigratedLanguageFileSync::modulesWithLocalChangesTakenOver(null, 'de'));
+ $this->assertSame([], MigratedLanguageFileSync::modulesWithLocalChangesTakenOver($this->client_data_dir, '../de'));
+ MigratedLanguageFileSync::forgetLocalChangesTakenOver(null, 'de');
+ MigratedLanguageFileSync::forgetLocalChangesTakenOver($this->client_data_dir, '../de');
+
+ $this->expectException(\InvalidArgumentException::class);
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, '../de', ['pilot']);
+ }
+
+ public function testForgettingRemovesOnlyTheRecordOfThatLanguage(): void
+ {
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'de', ['pilot']);
+ MigratedLanguageFileSync::markLocalChangesTakenOver($this->client_data_dir, 'fr', ['pilot']);
+
+ MigratedLanguageFileSync::forgetLocalChangesTakenOver($this->client_data_dir, 'de');
+
+ $this->assertSame([], $this->recorded());
+ $this->assertSame(['pilot'], $this->recorded('fr'));
+ }
+
+ /** A symbolic link is neither followed nor removed. */
+ public function testASymbolicLinkInPlaceOfTheRecordIsNeitherReadNorRemoved(): void
+ {
+ $target = $this->client_data_dir . '/elsewhere.json';
+ file_put_contents($target, '["pilot"]');
+ mkdir(dirname($this->recordFile()), 0775, true);
+ symlink($target, $this->recordFile());
+
+ $this->assertSame([], $this->recorded());
+ MigratedLanguageFileSync::forgetLocalChangesTakenOver($this->client_data_dir, 'de');
+
+ $this->assertTrue(is_link($this->recordFile()));
+ $this->assertFileExists($target);
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTestCase.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTestCase.php
new file mode 100644
index 000000000000..cf4af282ec5c
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTestCase.php
@@ -0,0 +1,157 @@
+fixture_directory, never written by the class under test) and the OVERLAY `.po`/`.mo` below a throwaway
+ * client data directory ($this->client_data_dir).
+ */
+abstract class MigratedLanguageFileSyncTestCase extends TestCase
+{
+ protected const string MODULE = 'stest';
+
+ protected string $fixture_directory;
+ protected string $client_data_dir;
+ protected string $ilias_root;
+ protected LanguageFileDirectory $directory;
+ protected LanguageFileDirectoryManager $manager;
+
+ protected function setUp(): void
+ {
+ // never the build of the installation, see MigratedPoFixture::resetRuntime()
+ MigratedPoFixture::resetRuntime();
+ parent::setUp();
+
+ $this->ilias_root = (string) realpath(__DIR__ . '/../../../../../');
+
+ $this->fixture_directory = __DIR__ . '/tmp-sync-fixtures-' . bin2hex(random_bytes(4));
+ mkdir($this->fixture_directory, 0775, true);
+ $this->client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+
+ $this->directory = MigratedPoFixture::directory(
+ self::MODULE,
+ 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/'
+ );
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->fixture_directory);
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+ MigratedPoFixture::resetRuntime();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @param array> $entries see MigratedPoFixture::catalog()
+ */
+ protected function seedShipped(array $entries, string $lang_key = 'de', string $module = self::MODULE): void
+ {
+ MigratedPoFixture::writePo($this->shippedPo($lang_key, $module), MigratedPoFixture::catalog($module, $entries));
+ }
+
+ /**
+ * @param array> $entries see MigratedPoFixture::catalog()
+ */
+ protected function seedOverlay(array $entries, string $lang_key = 'de'): void
+ {
+ MigratedPoFixture::writePair($this->overlayBase($lang_key), MigratedPoFixture::catalog(self::MODULE, $entries));
+ }
+
+ protected function shippedPo(string $lang_key = 'de', string $module = self::MODULE): string
+ {
+ return $this->fixture_directory . '/' . $module . '_' . $lang_key . '.po';
+ }
+
+ protected function overlayBase(string $lang_key = 'de'): string
+ {
+ return MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, $lang_key);
+ }
+
+ protected function overlayPo(): TranslationCatalog
+ {
+ return MigratedPoFixture::readPo($this->overlayBase() . '.po');
+ }
+
+ /**
+ * @param array $entries
+ */
+ protected function sync(array $entries, bool $refresh = false, ?string $client_data_dir = 'default'): void
+ {
+ MigratedLanguageFileSync::sync(
+ $this->manager,
+ $this->ilias_root,
+ 'de',
+ self::MODULE,
+ $entries,
+ $client_data_dir === 'default' ? $this->client_data_dir : $client_data_dir,
+ $refresh
+ );
+ }
+
+ /**
+ * @return list
+ */
+ protected function overlayIdentifiers(): array
+ {
+ return array_map(static fn($entry): string => $entry->getId(), $this->overlayPo()->getEntries());
+ }
+
+ protected function overlayDirectory(): string
+ {
+ return dirname($this->overlayBase());
+ }
+
+ /**
+ * @param list $lang_keys
+ * @return list
+ */
+ protected function findUnwritable(array $lang_keys = ['de'], ?string $client_data_dir = 'default', ?LanguageFileDirectoryManager $manager = null): array
+ {
+ return MigratedLanguageFileSync::findUnwritableOverlayDirectories(
+ $manager ?? $this->manager,
+ $this->ilias_root,
+ $lang_keys,
+ $client_data_dir === 'default' ? $this->client_data_dir : $client_data_dir
+ );
+ }
+
+
+ /**
+ * @return array
+ */
+ public static function refreshFlags(): array
+ {
+ return ['admin edit (refresh = false)' => [false], 'reconciling write (refresh = true)' => [true]];
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncUnwritableTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncUnwritableTest.php
new file mode 100644
index 000000000000..0e55d6c925a8
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncUnwritableTest.php
@@ -0,0 +1,163 @@
+seedShipped(['greeting' => 'Hallo']);
+ mkdir(dirname($this->overlayDirectory()), 0775, true);
+ file_put_contents($this->overlayDirectory(), 'not a directory');
+
+ $this->assertSame([$this->overlayDirectory()], $this->findUnwritable());
+ }
+
+ /** A file further up (where "/lang" is created) blocks the path just as well. */
+ public function testAFileInPlaceOfAParentOfTheOverlayDirectoryIsReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ file_put_contents($this->client_data_dir . '/lang', 'not a directory');
+
+ $this->assertSame([$this->overlayDirectory()], $this->findUnwritable());
+ }
+
+ /**
+ * A dangling symlink does not "exist" for file_exists(), but mkdir() cannot create anything in its place (overlay
+ * directory or a parent).
+ */
+ #[DataProvider('danglingSymlinkPositions')]
+ public function testADanglingSymlinkInTheOverlayPathIsReported(\Closure $link_path): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ $link = $link_path($this);
+ if (!is_dir(dirname($link))) {
+ mkdir(dirname($link), 0775, true);
+ }
+ symlink($this->client_data_dir . '/does-not-exist', $link);
+
+ $this->assertSame([$this->overlayDirectory()], $this->findUnwritable());
+ }
+
+ /**
+ * @return array>
+ */
+ public static function danglingSymlinkPositions(): array
+ {
+ return [
+ 'the overlay directory' => [static fn(self $test): string => $test->overlayDirectory()],
+ 'a parent ("lang")' => [static fn(self $test): string => $test->client_data_dir . '/lang'],
+ ];
+ }
+
+ /** A symlink to an existing writable directory is fine (e.g. a mounted data volume). */
+ public function testASymlinkToAWritableDirectoryIsNotReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ mkdir($this->client_data_dir . '/elsewhere');
+ symlink($this->client_data_dir . '/elsewhere', $this->client_data_dir . '/lang');
+
+ $this->assertSame([], $this->findUnwritable());
+ }
+
+ /** A missing directory is fine if the closest existing parent is writable: sync() creates it. */
+ public function testAMissingButCreatableOverlayDirectoryIsNotReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
+ $this->assertSame([], $this->findUnwritable());
+ $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang', 'the check itself creates nothing');
+ }
+
+ public function testAMissingClientDataDirWithACreatableParentIsNotReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+
+ $this->assertSame([], $this->findUnwritable(['de'], $this->client_data_dir . '/not-yet-created'));
+ }
+
+ public function testAnExistingWritableOverlayDirectoryIsNotReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ mkdir($this->overlayDirectory(), 0775, true);
+
+ $this->assertSame([], $this->findUnwritable());
+ }
+
+ public function testWithoutClientDataDirNothingIsReported(): void
+ {
+ $this->seedShipped(['greeting' => 'Hallo']);
+ file_put_contents($this->client_data_dir . '/lang', 'not a directory');
+
+ $this->assertSame([], $this->findUnwritable(['de'], null));
+ }
+
+ /**
+ * Only modules migrated for a given language count: without a shipped `.po` no overlay is written, so a blocked
+ * directory is irrelevant (as for modules without contributed directory and the main directory).
+ */
+ public function testAModuleThatIsNotMigratedForTheLanguageIsIgnored(): void
+ {
+ $this->seedShipped(['greeting' => 'Hello'], 'en');
+ file_put_contents($this->client_data_dir . '/lang', 'not a directory');
+
+ $this->assertSame([], $this->findUnwritable(['de']));
+ $this->assertSame([$this->client_data_dir . '/lang/' . self::MODULE . '/en'], $this->findUnwritable(['de', 'en']));
+ }
+
+ /**
+ * Each module has an overlay directory per language (`lang//`); each blocked one is reported once.
+ */
+ public function testEachUnwritableOverlayDirectoryIsReportedOnceAcrossLanguagesAndModules(): void
+ {
+ $second = MigratedPoFixture::directory('stwo', 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/sub/');
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory, $second);
+ $this->seedShipped(['greeting' => 'Hallo'], 'de');
+ $this->seedShipped(['greeting' => 'Hello'], 'en');
+ MigratedPoFixture::writePo(
+ $this->fixture_directory . '/sub/stwo_de.po',
+ MigratedPoFixture::catalog('stwo', ['x' => 'y'])
+ );
+ file_put_contents($this->client_data_dir . '/lang', 'not a directory');
+
+ $this->assertSame(
+ [
+ $this->client_data_dir . '/lang/' . self::MODULE . '/de',
+ $this->client_data_dir . '/lang/stwo/de',
+ $this->client_data_dir . '/lang/' . self::MODULE . '/en',
+ ],
+ $this->findUnwritable(['de', 'en', 'de'], 'default', $manager)
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedTranslationsTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedTranslationsTest.php
new file mode 100644
index 000000000000..7b4a70d97104
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedTranslationsTest.php
@@ -0,0 +1,667 @@
+ the warnings the component logger received
+ */
+ private array $warnings = [];
+
+ protected function setUp(): void
+ {
+ MigratedPoFixture::resetRuntime();
+ $this->root = sys_get_temp_dir() . '/ilias_migrated_translations_' . bin2hex(random_bytes(6));
+ $this->client_data_dir = $this->root . '/client';
+ mkdir($this->client_data_dir, 0775, true);
+
+ $this->warnings = [];
+ $logger = $this->createStub(\ilLogger::class);
+ $logger->method('warning')->willReturnCallback(function (string $message): void {
+ $this->warnings[] = $message;
+ });
+ $factory = $this->createStub(\ilLoggerFactory::class);
+ $factory->method('getComponentLogger')->willReturn($logger);
+ $GLOBALS['DIC'] = new Container();
+ $GLOBALS['DIC']['ilLoggerFactory'] = static fn() => $factory;
+ }
+
+ protected function tearDown(): void
+ {
+ MigratedPoFixture::removeDirectory($this->root);
+ MigratedPoFixture::resetRuntime();
+ }
+
+ /**
+ * Module "mtr" (de: greeting, same = its own identifier, a plural item; en: greeting, only_en) and
+ * module "mtz" (de: other), built.
+ */
+ private function build(): void
+ {
+ $item = new TranslationEntry('mtr', 'item');
+ $item->setPlural('items', ['Eintrag', 'Einträge']);
+ $de = MigratedPoFixture::catalog('mtr', ['greeting' => 'Hallo', 'same' => 'same']);
+ $de->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $de->add($item);
+ MigratedPoFixture::writePo($this->root . '/mtr/mtr_de.po', $de);
+ MigratedPoFixture::writePo($this->root . '/mtr/mtr_en.po', MigratedPoFixture::catalog('mtr', ['greeting' => 'Hello', 'only_en' => 'Only']));
+ MigratedPoFixture::writePo($this->root . '/mtz/mtz_de.po', MigratedPoFixture::catalog('mtz', ['other' => 'Anders']));
+
+ $manager = new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ MigratedPoFixture::directory('mtr', 'mtr/'),
+ MigratedPoFixture::directory('mtz', 'mtz/')
+ );
+ $build = MigratedPoFixture::build($manager, $this->root)['build'];
+ $this->assertNotNull($build);
+ $this->build_directory = MigratedLanguageFilePaths::buildDirectory(MigratedPoFixture::artifactDirectory(), $build);
+ }
+
+ /**
+ * @param array $entries
+ * @return string the revision directory
+ */
+ private function writeOverlay(array $entries, string $module = 'mtr', string $lang_key = 'de'): string
+ {
+ $overlay_directory = MigratedLanguageFilePaths::overlayDirectory($this->client_data_dir, $module, $lang_key);
+ MigratedPoFixture::writeOverlayRevision($overlay_directory . '/' . $module . '_' . $lang_key, MigratedPoFixture::catalog($module, $entries));
+
+ return MigratedLanguageFilePaths::overlayRevisionDirectory(
+ $overlay_directory,
+ trim((string) file_get_contents(MigratedLanguageFilePaths::overlayCurrentFile($overlay_directory)))
+ );
+ }
+
+ private function text(string $key, string $module = 'mtr', string $lang_key = 'de'): ?string
+ {
+ return (new TranslationGettextRepository())->getText($module, $lang_key, $key, $this->client_data_dir);
+ }
+
+ /**
+ * @return array|null
+ */
+ private function keys(string $module = 'mtr', string $lang_key = 'de'): ?array
+ {
+ return (new TranslationGettextRepository())->getKeys($module, $lang_key, $this->client_data_dir);
+ }
+
+ public function testServesTheBuildAndTellsTranslatedAsItselfFromNotTranslated(): void
+ {
+ $this->build();
+
+ $this->assertSame(['greeting', 'item', 'only_en', 'same'], array_keys($this->keys() ?? []), 'the identifiers of every language of the module');
+ $this->assertSame('Hallo', $this->text('greeting'));
+ $this->assertSame('Hello', $this->text('greeting', 'mtr', 'en'));
+ $this->assertSame('same', $this->text('same'), 'a value equal to its identifier is a value (marker ilias-identity)');
+ $this->assertNull($this->text('only_en'), 'listed, but not translated in this language');
+ $this->assertNull($this->text('unknown'));
+ $this->assertSame([], $this->warnings, 'an untranslated key is no problem');
+ }
+
+ public function testAModuleOrLanguageThatWasNotBuiltIsNotMigrated(): void
+ {
+ $this->build();
+
+ $this->assertNull($this->keys('mtz', 'en'));
+ $this->assertNull($this->keys('mtr', 'fr'));
+ $this->assertNull($this->keys('nomodule', 'de'));
+ $this->assertNull($this->keys('../mtr', 'de'), 'a module name is no path');
+ $this->assertNull($this->text('greeting', 'mtr', 'fr'));
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testTheValueOfAPluralMessageIsItsLastFormAndPluralTextSelectsByQuantity(): void
+ {
+ $this->build();
+
+ $this->assertSame('Einträge', $this->text('item'), 'the default form of a plural message is the last one (see PluralForms)');
+ $this->assertSame('Eintrag', (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 1, $this->client_data_dir));
+ $this->assertSame('Einträge', (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 0, $this->client_data_dir));
+ $this->assertNull((new TranslationGettextRepository())->getPluralText('mtr', 'de', 'greeting', 2, $this->client_data_dir), 'no plural message');
+ $this->assertNull((new TranslationGettextRepository())->getPluralText('mtr', 'de', 'unknown', 2, $this->client_data_dir));
+ }
+
+ public function testTheOverlayWinsAndBringsItsOwnIdentifiers(): void
+ {
+ $this->build();
+ $this->writeOverlay(['greeting' => 'Servus', 'extra' => 'Zusatz', 'item' => 'Datensatz']);
+
+ $this->assertSame('Servus', $this->text('greeting'));
+ $this->assertSame('Zusatz', $this->text('extra'));
+ $this->assertSame('Hello', $this->text('greeting', 'mtr', 'en'), 'the overlay of another language is not involved');
+ $this->assertSame('same', $this->text('same'), 'not in the overlay: the shipped value');
+ $this->assertContains('extra', array_keys($this->keys() ?? []));
+ $this->assertSame('Datensatz', $this->text('item'));
+ $this->assertNull(
+ (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 5, $this->client_data_dir),
+ 'a singular value in the overlay replaces the plural message'
+ );
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testAnOverlayValueEqualToItsIdentifierIsAValueNotAMissingTranslation(): void
+ {
+ $this->build();
+ $this->writeOverlay(['greeting' => 'greeting']);
+
+ $this->assertSame('greeting', $this->text('greeting'), 'not the shipped "Hallo", not null');
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function unreadableOverlays(): iterable
+ {
+ yield 'current names no valid revision' => [static function (string $revision_directory): void {
+ file_put_contents(dirname($revision_directory) . '/current', "not-a-revision\n");
+ }];
+ yield 'current names a revision that is gone' => [static function (string $revision_directory): void {
+ MigratedPoFixture::removeDirectory($revision_directory);
+ }];
+ yield 'keys.json is not JSON' => [static function (string $revision_directory): void {
+ file_put_contents($revision_directory . '/keys.json', '{nope');
+ }];
+ yield 'keys.json is an object' => [static function (string $revision_directory): void {
+ file_put_contents($revision_directory . '/keys.json', '{"greeting":"extra"}');
+ }];
+ yield 'keys.json holds something that is no string' => [static function (string $revision_directory): void {
+ file_put_contents($revision_directory . '/keys.json', '["extra", 5]');
+ }];
+ yield 'the catalog is not readable' => [static function (string $revision_directory): void {
+ file_put_contents($revision_directory . '/messages/LC_MESSAGES/mtr.de.overlay.mo', 'garbage');
+ }];
+ }
+
+ /**
+ * @param \Closure(string): void $damage
+ */
+ #[DataProvider('unreadableOverlays')]
+ public function testAnOverlayThatCannotBeReadIsLoggedOnceAndTheShippedValuesAreServed(\Closure $damage): void
+ {
+ $this->build();
+ $damage($this->writeOverlay(['greeting' => 'Servus', 'extra' => 'Zusatz']));
+
+ for ($i = 0; $i < 3; $i++) {
+ $this->assertSame('Hallo', $this->text('greeting'));
+ $this->assertNull($this->text('extra'));
+ }
+
+ $this->assertArrayNotHasKey('extra', $this->keys() ?? []);
+ $this->assertCount(1, $this->warnings, 'once per request, not once per lookup');
+ $this->assertStringContainsString('local changes are not served', $this->warnings[0]);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function symbolicLinkLevels(): iterable
+ {
+ yield 'lang/' => ['module'];
+ yield '' => ['lang'];
+ yield 'current' => ['current'];
+ yield 'current, dangling' => ['dangling'];
+ yield 'the revision directory' => ['revision'];
+ yield 'messages' => ['messages'];
+ yield 'LC_MESSAGES' => ['catalogs'];
+ yield 'the catalog' => ['mo'];
+ yield 'keys.json' => ['keys'];
+ }
+
+ /**
+ * Like the write path, the read path follows no symlink below the overlay root: the files are readable through the
+ * link and still not served.
+ */
+ #[DataProvider('symbolicLinkLevels')]
+ public function testAnOverlayWithASymbolicLinkAnywhereBelowTheOverlayRootIsNotServed(string $level): void
+ {
+ $this->build();
+ $revision_directory = $this->writeOverlay(['greeting' => 'Servus', 'extra' => 'Zusatz']);
+ $overlay_directory = dirname($revision_directory);
+ $path = match ($level) {
+ 'module' => dirname($overlay_directory),
+ 'lang' => $overlay_directory,
+ 'current', 'dangling' => $overlay_directory . '/current',
+ 'revision' => $revision_directory,
+ 'messages' => $revision_directory . '/messages',
+ 'catalogs' => $revision_directory . '/' . NativeGettext::CATALOG_DIRECTORY,
+ 'mo' => MigratedLanguageFilePaths::overlayMoFile($revision_directory, 'mtr', 'de'),
+ 'keys' => $revision_directory . '/keys.json',
+ default => throw new \LogicException($level),
+ };
+ if ($level === 'dangling') {
+ unlink($path);
+ symlink($this->root . '/does-not-exist', $path);
+ } else {
+ mkdir($this->root . '/away', 0775, true);
+ $target = $this->root . '/away/' . bin2hex(random_bytes(4));
+ rename($path, $target);
+ symlink($target, $path);
+ }
+
+ $this->assertSame('Hallo', $this->text('greeting'));
+ $this->assertNull($this->text('extra'));
+ $this->assertArrayNotHasKey('extra', $this->keys() ?? []);
+ $this->assertCount(1, $this->warnings);
+ $this->assertStringContainsString('symbolic link', $this->warnings[0]);
+ }
+
+ public function testACurrentFileLargerThanARevisionNameIsNotServedAndOnlyItsStartIsRead(): void
+ {
+ $this->build();
+ $revision_directory = $this->writeOverlay(['greeting' => 'Servus']);
+ $revision = basename($revision_directory);
+ $current = dirname($revision_directory) . '/current';
+ // the revision name is no longer at the start, and the file is far larger than any name
+ file_put_contents($current, str_repeat('x', 200) . $revision . "\n" . str_repeat('y', 1_000_000));
+
+ $this->assertSame('Hallo', $this->text('greeting'));
+ $this->assertCount(1, $this->warnings);
+ $this->assertStringContainsString('names no valid revision', $this->warnings[0]);
+ }
+
+ public function testOnlyTheStartOfACurrentFileIsRead(): void
+ {
+ $this->build();
+ $revision_directory = $this->writeOverlay(['greeting' => 'Servus']);
+ $current = dirname($revision_directory) . '/current';
+ // the revision name, then far more than a revision file ever holds
+ file_put_contents($current, basename($revision_directory) . str_repeat(' ', 200) . 'trailing');
+
+ $this->assertSame('Servus', $this->text('greeting'));
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testAPluralMessageIsServedAgainAfterAForeignReset(): void
+ {
+ $this->build();
+ $this->assertSame('Eintrag', (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 1, $this->client_data_dir), 'precondition');
+ setlocale(LC_MESSAGES, 'C');
+
+ $this->assertSame('Einträge', (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 5, $this->client_data_dir));
+ $this->assertSame([], $this->warnings);
+ }
+
+ /**
+ * Native gettext losing its settings without being able to restore them: nothing is served, not even the identifier
+ * for a value equal to it (as the overlay does for a listed identifier; txt() shows "-key-").
+ */
+ public function testLostNativeGettextServesNothingNotEvenTheIdentifierForAnOverlayValue(): void
+ {
+ $this->build();
+ $plural = MigratedPoFixture::catalog('mtr', ['greeting' => 'Servus', 'same' => 'same']);
+ $plural->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $item = new TranslationEntry('mtr', 'item');
+ $item->setPlural('items', ['Datensatz', 'Datensätze']);
+ $plural->add($item);
+ $overlay_directory = MigratedLanguageFilePaths::overlayDirectory($this->client_data_dir, 'mtr', 'de');
+ MigratedPoFixture::writeOverlayRevision($overlay_directory . '/mtr_de', $plural);
+ $this->assertSame('Servus', $this->text('greeting'), 'precondition');
+ $this->assertSame('same', $this->text('same'), 'precondition');
+ $this->assertSame('Datensätze', (new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 5, $this->client_data_dir), 'precondition');
+ // another part of the request resets LC_MESSAGES, and the locale cannot be activated again
+ setlocale(LC_MESSAGES, 'C');
+ MigratedPoFixture::removeDirectory(MigratedLanguageFilePaths::buildLocaleDirectory($this->build_directory) . '/' . NativeGettext::LOCALE_NAME);
+
+ $this->assertNull($this->text('greeting'), 'overlay value');
+ $this->assertNull($this->text('same'), 'overlay value equal to its identifier');
+ $this->assertNull($this->text('other', 'mtz'), 'shipped value of a module read for the first time only now');
+ $this->assertNull((new TranslationGettextRepository())->getPluralText('mtr', 'de', 'item', 5, $this->client_data_dir));
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+ $this->assertTrue($problem['fatal']);
+ $this->assertStringContainsString('Native gettext is not available', $problem['message']);
+ $this->assertCount(1, $this->warnings);
+ }
+
+ public function testNativeGettextThatIsActivatedAgainServesTheOverlayValueAfterAForeignReset(): void
+ {
+ $this->build();
+ $this->writeOverlay(['greeting' => 'Servus', 'same' => 'same']);
+ $this->assertSame('Servus', $this->text('greeting'), 'precondition');
+ setlocale(LC_MESSAGES, 'C');
+
+ $this->assertSame('Servus', $this->text('greeting'));
+ $this->assertSame('same', $this->text('same'));
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testWithoutAClientDataDirOrAnOverlayTheShippedValuesAreServedWithoutAWarning(): void
+ {
+ $this->build();
+
+ $this->assertSame('Hallo', (new TranslationGettextRepository())->getText('mtr', 'de', 'greeting', null));
+ $this->assertSame('Hallo', $this->text('greeting'), 'no overlay of this module and language: no current file');
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testANewRevisionIsServedAfterInvalidateAlsoWhenTheProcessReadTheEarlierOnes(): void
+ {
+ $this->build();
+
+ foreach (['Servus', 'Moin', 'Grüß Gott', 'Servus'] as $value) {
+ $this->writeOverlay(['greeting' => $value]);
+ (new TranslationGettextRepository())->invalidateModule('mtr', 'de');
+
+ $this->assertSame($value, $this->text('greeting'));
+ }
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testForgetLanguageDropsOnlyThatLanguage(): void
+ {
+ $this->build();
+ $this->writeOverlay(['greeting' => 'Servus']);
+ $this->assertSame('Servus', $this->text('greeting'));
+ $this->assertSame('Hello', $this->text('greeting', 'mtr', 'en'));
+ $this->writeOverlay(['greeting' => 'Moin']);
+ $this->writeOverlay(['greeting' => 'Cheers'], 'mtr', 'en');
+
+ (new TranslationGettextRepository())->invalidateLanguage('de');
+
+ $this->assertSame('Moin', $this->text('greeting'));
+ $this->assertSame('Hello', $this->text('greeting', 'mtr', 'en'), 'en was not forgotten (its overlay is not read again)');
+ }
+
+ public function testWithoutABuildNothingIsMigratedAndTheProblemSaysSo(): void
+ {
+ $this->assertNull($this->keys());
+ $this->assertNull($this->text('greeting'));
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertStringContainsString('There is no build', $problem['message']);
+ $this->assertTrue($problem['fatal']);
+ $this->assertCount(1, $this->warnings);
+ }
+
+ public function testAHealthyBuildHasNoProblem(): void
+ {
+ $this->build();
+
+ $this->assertNull(MigratedTranslations::getProblem($this->client_data_dir));
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testNoBuildIsAFatalProblemWithoutAbsolutePaths(): void
+ {
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertTrue($problem['fatal']);
+ $this->assertStringContainsString('"artifacts/language/current.json"', $problem['message']);
+ $this->assertStringNotContainsString(sys_get_temp_dir(), $problem['message']);
+ }
+
+ public function testAProblemOfOneModuleIsNotFatalAndHasNoAbsolutePaths(): void
+ {
+ $this->build();
+ unlink(MigratedLanguageFilePaths::buildKeysFile($this->build_directory, 'mtr'));
+ MigratedTranslations::reset();
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertFalse($problem['fatal']);
+ $this->assertStringContainsString('"artifacts/language/' . basename($this->build_directory) . '/keys/mtr.php"', $problem['message']);
+ $this->assertStringNotContainsString(sys_get_temp_dir(), $problem['message']);
+ }
+
+ public function testAFatalProblemIsReportedBeforeAnEarlierNonFatalOne(): void
+ {
+ $this->build();
+ unlink(MigratedLanguageFilePaths::buildKeysFile($this->build_directory, 'mtr'));
+ file_put_contents(MigratedLanguageFilePaths::buildMoFile($this->build_directory, 'mtz', 'de'), 'garbage');
+ MigratedTranslations::reset();
+ $this->keys('mtr');
+ $this->keys('mtz');
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertTrue($problem['fatal']);
+ $this->assertStringContainsString('mtz.de.mo', $problem['message']);
+ }
+
+ public function testAnOverlayProblemNamesItsPathRelativeToTheClientDataDirectory(): void
+ {
+ $this->build();
+ $revision_directory = $this->writeOverlay(['greeting' => 'Servus']);
+ file_put_contents($revision_directory . '/keys.json', '{nope');
+ $this->keys();
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertFalse($problem['fatal']);
+ $this->assertStringContainsString('"/lang/mtr/de/' . basename($revision_directory) . '"', $problem['message']);
+ $this->assertStringNotContainsString($this->client_data_dir, $problem['message']);
+ }
+
+ public function testGetProblemChecksTheFirstModuleOfTheBuildEvenIfNothingWasUsedYet(): void
+ {
+ $this->build();
+ unlink(MigratedLanguageFilePaths::buildKeysFile($this->build_directory, 'mtr'));
+ MigratedTranslations::reset();
+
+ $problem = MigratedTranslations::getProblem($this->client_data_dir);
+ $this->assertNotNull($problem);
+
+ $this->assertStringContainsString('identifiers of module "mtr" cannot be read', $problem['message']);
+ $this->assertFalse($problem['fatal'], 'concerns one module only');
+ $this->assertStringNotContainsString(MigratedPoFixture::artifactDirectory(), $problem['message'], 'no full path in the message for the GUI');
+ }
+
+ public function testABuildWithoutTheIdentifiersOfAModuleServesNothingOfIt(): void
+ {
+ $this->build();
+ unlink(MigratedLanguageFilePaths::buildKeysFile($this->build_directory, 'mtr'));
+ MigratedTranslations::reset();
+
+ $this->assertSame([], $this->keys(), 'migrated, but without any identifier');
+ $this->assertNull($this->text('greeting'));
+ $this->assertCount(1, $this->warnings);
+ $this->assertSame('Anders', $this->text('other', 'mtz'), 'another module is not affected');
+ }
+
+ public function testTheSameProblemOfAnotherLanguageOfTheModuleIsNotLoggedAgain(): void
+ {
+ $this->build();
+ unlink(MigratedLanguageFilePaths::buildKeysFile($this->build_directory, 'mtr'));
+ MigratedTranslations::reset();
+
+ $this->assertSame([], $this->keys('mtr', 'de'));
+ $this->assertSame([], $this->keys('mtr', 'en'));
+
+ $this->assertCount(1, $this->warnings, 'one message per request');
+ }
+
+ public function testACatalogOfTheBuildThatCannotBeReadIsLoggedAndNothingOfItIsServed(): void
+ {
+ $this->build();
+ // never read by this process (the build checks one catalog only), so it is read as it is now
+ file_put_contents(MigratedLanguageFilePaths::buildMoFile($this->build_directory, 'mtz', 'de'), 'garbage');
+
+ $this->assertSame(['other'], array_keys($this->keys('mtz') ?? []), 'its identifiers are known');
+ $this->assertNull($this->text('other', 'mtz'));
+ $this->assertNull($this->text('other', 'mtz'));
+
+ $this->assertCount(1, $this->warnings);
+ $this->assertStringContainsString('cannot read', $this->warnings[0]);
+ $this->assertSame('Hallo', $this->text('greeting'), 'another module is not affected');
+ }
+
+ public function testResetForgetsTheBuildSoANewOneIsServed(): void
+ {
+ $this->build();
+ $this->assertSame('Hallo', $this->text('greeting'));
+
+ MigratedPoFixture::writePo($this->root . '/mtr/mtr_de.po', MigratedPoFixture::catalog('mtr', ['greeting' => 'Guten Tag']));
+ $manager = new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ MigratedPoFixture::directory('mtr', 'mtr/'),
+ MigratedPoFixture::directory('mtz', 'mtz/')
+ );
+ MigratedPoFixture::build($manager, $this->root);
+ MigratedTranslations::reset();
+
+ $this->assertSame('Guten Tag', $this->text('greeting'));
+ }
+
+ public function testAnOverlayIsServedOnceTheClientDataDirectoryBecomesKnown(): void
+ {
+ $this->build();
+ $this->writeOverlay(['greeting' => 'Overlay']);
+
+ $this->assertSame('Hallo', (new TranslationGettextRepository())->getText('mtr', 'de', 'greeting', null), 'Setup before the client data directory is known');
+ $this->assertSame('Hallo', (new TranslationGettextRepository())->getText('mtr', 'de', 'greeting', null), 'still unknown on the next call');
+ $this->assertSame('Overlay', $this->text('greeting'));
+ $this->assertSame([], $this->warnings);
+ }
+
+ public function testTheOverlayIsCheckedOnlyOnceAfterTheClientDataDirectoryIsKnown(): void
+ {
+ $this->build();
+
+ $this->assertSame('Hallo', $this->text('greeting'), 'no overlay yet');
+ $this->writeOverlay(['greeting' => 'Late']);
+
+ $this->assertSame('Hallo', $this->text('greeting'), 'not looked at again without invalidate()');
+ (new TranslationGettextRepository())->invalidateModule('mtr', 'de');
+ $this->assertSame('Late', $this->text('greeting'));
+ }
+
+ /** The state is static: what one instance read is dropped by the invalidation of another (ilLanguage creates its own instances). */
+ public function testInvalidationThroughOneInstanceReachesTheOtherInstances(): void
+ {
+ $this->build();
+ $reader = new TranslationGettextRepository();
+ $invalidator = new TranslationGettextRepository();
+
+ $this->assertSame('Hallo', $reader->getText('mtr', 'de', 'greeting', $this->client_data_dir));
+ $this->writeOverlay(['greeting' => 'Late']);
+ $this->assertSame('Hallo', $reader->getText('mtr', 'de', 'greeting', $this->client_data_dir), 'cached');
+
+ $invalidator->invalidateModule('mtr', 'de');
+ $this->assertSame('Late', $reader->getText('mtr', 'de', 'greeting', $this->client_data_dir));
+
+ $this->writeOverlay(['greeting' => 'Later']);
+ $invalidator->invalidateLanguage('de');
+ $this->assertSame('Later', $reader->getText('mtr', 'de', 'greeting', $this->client_data_dir));
+ }
+
+ public function testAModuleIsShippedIfItIsBuiltOrHasATemplateInTheComponents(): void
+ {
+ $this->assertFalse(MigratedTranslations::isShippedModule('mtr'), 'no build, no template');
+ $this->assertTrue(MigratedTranslations::isShippedModule('tos'), 'without a build: template of the TermsOfService component');
+ $this->build();
+ MigratedTranslations::reset();
+
+ $this->assertTrue(MigratedTranslations::isShippedModule('mtr'), 'built');
+ $this->assertFalse(MigratedTranslations::isShippedModule('tos'), 'with a build only its recorded contributions count');
+ $this->assertFalse(MigratedTranslations::isShippedModule('some_plugin_module'));
+ }
+
+ public function testWithABuildTheRecordedContributionsDecideEvenForAModuleWithoutAnyPo(): void
+ {
+ mkdir($this->root . '/mtq', 0775, true);
+ MigratedPoFixture::writePo($this->root . '/mtr/mtr_de.po', MigratedPoFixture::catalog('mtr', ['greeting' => 'Hallo']));
+ MigratedPoFixture::build(
+ new LanguageFileDirectoryManager(
+ new CustomizingLanguageFileDirectory(),
+ MigratedPoFixture::directory('mtr', 'mtr/'),
+ MigratedPoFixture::directory('mtq', 'mtq/')
+ ),
+ $this->root
+ );
+
+ $this->assertTrue(MigratedTranslations::isShippedModule('mtq'), 'contributed, no .po, no template');
+ $this->assertFalse(MigratedTranslations::isShippedModule('tos'), 'a template the build did not record');
+ }
+
+ /**
+ * @return array whether the build has any .po
+ */
+ public static function listsNobodyCanRelyOn(): array
+ {
+ return ['a pointer without build' => [false], 'a pointer written before the list existed' => [true]];
+ }
+
+ #[DataProvider('listsNobodyCanRelyOn')]
+ public function testWithoutABuildOrAListOfContributionsTheShippedTemplatesDecide(bool $with_build): void
+ {
+ mkdir($this->root . '/mtr', 0775, true);
+ if ($with_build) {
+ $this->build();
+ $pointer_file = MigratedLanguageFilePaths::buildPointerFile(MigratedPoFixture::artifactDirectory());
+ $data = json_decode((string) file_get_contents($pointer_file), true, 512, JSON_THROW_ON_ERROR);
+ unset($data['shipped']);
+ file_put_contents($pointer_file, json_encode($data));
+ } else {
+ MigratedPoFixture::build(
+ new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), MigratedPoFixture::directory('mtr', 'mtr/')),
+ $this->root
+ );
+ }
+ MigratedTranslations::reset();
+
+ $this->assertTrue(MigratedTranslations::isShippedModule('tos'), 'template of the TermsOfService component');
+ $this->assertFalse(MigratedTranslations::isShippedModule('some_plugin_module'));
+ $this->assertSame($with_build, MigratedTranslations::isShippedModule('mtr'), 'built, but in no template');
+ }
+
+ public function testShipsPoForNeedsATemplateADefaultNamedPoAndAValidLanguageKey(): void
+ {
+ $this->assertTrue(MigratedTranslations::shipsPoFor('tos', 'de'));
+ $this->assertFalse(MigratedTranslations::shipsPoFor('tos', 'xx'), 'no such .po');
+ $this->assertFalse(MigratedTranslations::shipsPoFor('tos', '../de'), 'no path');
+ $this->assertFalse(MigratedTranslations::shipsPoFor('some_plugin_module', 'de'), 'no template');
+ }
+
+ public function testReportMissingBuildIsFatalWithoutABuildAndSilentWithOne(): void
+ {
+ $this->build();
+ $this->assertFalse(MigratedTranslations::reportMissingBuild());
+ $this->assertSame([], $this->warnings);
+
+ MigratedPoFixture::resetRuntime();
+ $this->assertTrue(MigratedTranslations::reportMissingBuild());
+ $this->assertCount(1, $this->warnings);
+ $this->assertStringContainsString('There is no build', implode("\n", $this->warnings));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/NativeGettextTest.php b/components/ILIAS/Language/tests/ComponentTranslation/NativeGettextTest.php
new file mode 100644
index 000000000000..4a0390427d43
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/NativeGettextTest.php
@@ -0,0 +1,359 @@
+process_state = [
+ 'language' => getenv('LANGUAGE'),
+ 'locpath' => getenv('LOCPATH'),
+ 'messages' => setlocale(LC_MESSAGES, '0'),
+ ];
+ $this->root = sys_get_temp_dir() . '/ilias_native_gettext_' . bin2hex(random_bytes(6));
+ mkdir($this->root, 0775, true);
+
+ $catalog = MigratedPoFixture::catalog('ntg', ['greeting' => 'Hallo']);
+ $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
+ $item = new TranslationEntry('ntg', 'item');
+ $item->setPlural('items', ['Eintrag', 'Einträge']);
+ $catalog->add($item);
+ MigratedPoFixture::writePo($this->root . '/ntg/ntg_de.po', $catalog);
+ $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), MigratedPoFixture::directory('ntg', 'ntg/'));
+ $build = MigratedPoFixture::build($manager, $this->root)['build'];
+ // the build checked itself through native gettext in this process, see ShippedLanguageFilesCompiledObjective
+ NativeGettext::restore();
+ $this->assertNotNull($build);
+ $this->build_directory = MigratedLanguageFilePaths::buildDirectory(MigratedPoFixture::artifactDirectory(), $build);
+ $this->locale_directory = MigratedLanguageFilePaths::buildLocaleDirectory($this->build_directory);
+ }
+
+ protected function tearDown(): void
+ {
+ NativeGettext::resetForTests();
+ // whatever a test did to the process, the next one starts as this one did
+ putenv($this->process_state['language'] === false ? 'LANGUAGE' : 'LANGUAGE=' . $this->process_state['language']);
+ putenv($this->process_state['locpath'] === false ? 'LOCPATH' : 'LOCPATH=' . $this->process_state['locpath']);
+ if ($this->process_state['messages'] !== false) {
+ setlocale(LC_MESSAGES, $this->process_state['messages']);
+ }
+ MigratedPoFixture::removeDirectory($this->root);
+ MigratedPoFixture::resetRuntime();
+ }
+
+ private function activateBuild(): void
+ {
+ $this->assertTrue(NativeGettext::activate($this->locale_directory), (string) NativeGettext::getProblem());
+ $this->assertTrue(NativeGettext::bind(self::DOMAIN, $this->build_directory));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function previousProcessSettings(): iterable
+ {
+ yield 'nothing set before' => [false, false];
+ yield 'LANGUAGE set before' => ['de:fr', false];
+ yield 'LOCPATH set before' => [false, '/nonexistent/locpath'];
+ yield 'both set before' => ['en', '/nonexistent/locpath'];
+ }
+
+ #[DataProvider('previousProcessSettings')]
+ public function testActivateServesTheBuildAndRestoreGivesTheProcessStateBack(string|false $language, string|false $locpath): void
+ {
+ putenv($language === false ? 'LANGUAGE' : 'LANGUAGE=' . $language);
+ putenv($locpath === false ? 'LOCPATH' : 'LOCPATH=' . $locpath);
+ $messages_before = setlocale(LC_MESSAGES, '0');
+
+ $this->activateBuild();
+
+ $this->assertTrue(NativeGettext::isActive());
+ $this->assertSame(NativeGettext::LOCALE_NAME, setlocale(LC_MESSAGES, '0'));
+ $this->assertSame(NativeGettext::CATALOG_LANGUAGE, getenv('LANGUAGE'));
+ $this->assertSame($locpath, getenv('LOCPATH'), 'LOCPATH only lives for the setlocale() call (no system locale is found while it is set)');
+ $this->assertSame('Hallo', NativeGettext::translate(self::DOMAIN, 'greeting'));
+
+ NativeGettext::restore();
+
+ $this->assertFalse(NativeGettext::isActive());
+ $this->assertSame($messages_before, setlocale(LC_MESSAGES, '0'));
+ $this->assertSame($language, getenv('LANGUAGE'));
+ $this->assertSame($locpath, getenv('LOCPATH'));
+ $this->assertSame('greeting', NativeGettext::translate(self::DOMAIN, 'greeting'), 'without the locale native gettext answers with the message id');
+ }
+
+ public function testRestoreLeavesASettingOtherCodeChangedSinceAsItIs(): void
+ {
+ $this->activateBuild();
+ setlocale(LC_MESSAGES, 'C');
+ putenv('LANGUAGE=fr');
+
+ NativeGettext::restore();
+
+ $this->assertSame('C', setlocale(LC_MESSAGES, '0'));
+ $this->assertSame('fr', getenv('LANGUAGE'));
+ }
+
+ public function testRestoreWithoutActivationChangesNothing(): void
+ {
+ putenv('LANGUAGE=de');
+ $messages = setlocale(LC_MESSAGES, '0');
+
+ NativeGettext::restore();
+
+ $this->assertSame('de', getenv('LANGUAGE'));
+ $this->assertSame($messages, setlocale(LC_MESSAGES, '0'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function directoriesWithoutAUsableLocale(): iterable
+ {
+ yield 'no locale directory' => ['none'];
+ yield 'an empty locale directory' => ['empty'];
+ }
+
+ #[DataProvider('directoriesWithoutAUsableLocale')]
+ public function testActivateFailsWithAProblemAndLeavesTheProcessAsItWas(string $kind): void
+ {
+ $directory = $this->root . '/locale-' . $kind;
+ mkdir($directory . ($kind === 'empty' ? '/' . NativeGettext::LOCALE_NAME : ''), 0775, true);
+ putenv('LANGUAGE=de');
+ putenv('LOCPATH=/nonexistent/locpath');
+ $messages = setlocale(LC_MESSAGES, '0');
+
+ $this->assertFalse(NativeGettext::activate($directory));
+
+ $this->assertStringContainsString(NativeGettext::LOCALE_NAME, (string) NativeGettext::getProblem());
+ $this->assertFalse(NativeGettext::isActive());
+ $this->assertSame($messages, setlocale(LC_MESSAGES, '0'));
+ $this->assertSame('de', getenv('LANGUAGE'));
+ $this->assertSame('/nonexistent/locpath', getenv('LOCPATH'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function foreignChanges(): iterable
+ {
+ yield 'LC_MESSAGES set to C' => [static function (): void {
+ setlocale(LC_MESSAGES, 'C');
+ }];
+ yield 'LC_ALL set to C (what initLocale() does)' => [static function (): void {
+ setlocale(LC_ALL, 'C');
+ }];
+ yield 'LANGUAGE changed' => [static function (): void {
+ putenv('LANGUAGE=de');
+ }];
+ yield 'LANGUAGE removed' => [static function (): void {
+ putenv('LANGUAGE');
+ }];
+ }
+
+ #[DataProvider('foreignChanges')]
+ public function testReactivateIfLostRepairsWhatOtherCodeChanged(\Closure $change): void
+ {
+ $this->activateBuild();
+ $change();
+ $this->assertFalse(NativeGettext::isActive(), 'precondition');
+ $this->assertSame('greeting', NativeGettext::translate(self::DOMAIN, 'greeting'), 'precondition: nothing is translated now');
+
+ $this->assertTrue(NativeGettext::reactivateIfLost());
+
+ $this->assertTrue(NativeGettext::isActive());
+ $this->assertSame('Hallo', NativeGettext::translate(self::DOMAIN, 'greeting'));
+ }
+
+ public function testReactivateIfLostDoesNothingWithoutAnActivationOrWhileActive(): void
+ {
+ // setUp()'s build activated native gettext - restore() keeps that locale for a reactivation
+ NativeGettext::resetForTests();
+ $this->assertFalse(NativeGettext::reactivateIfLost(), 'never activated');
+ $this->assertNotSame(NativeGettext::LOCALE_NAME, setlocale(LC_MESSAGES, '0'));
+
+ $this->activateBuild();
+ $this->assertFalse(NativeGettext::reactivateIfLost(), 'still active');
+ }
+
+ public function testAfterRestoreReactivateIfLostActivatesTheLocaleAgain(): void
+ {
+ $this->activateBuild();
+ NativeGettext::restore();
+ $this->assertFalse(NativeGettext::isActive(), 'precondition');
+
+ $this->assertTrue(NativeGettext::reactivateIfLost());
+ $this->assertSame('Hallo', NativeGettext::translate(self::DOMAIN, 'greeting'));
+ }
+
+ public function testRestoreChangesNothingInAThreadSafePhp(): void
+ {
+ $this->activateBuild();
+ NativeGettext::useThreadSafeForTests(true);
+
+ NativeGettext::restore();
+
+ $this->assertTrue(NativeGettext::isThreadSafe());
+ $this->assertTrue(NativeGettext::isActive(), 'other threads may be using the settings');
+ }
+
+ /**
+ * In a thread-safe PHP restore() does nothing, but another thread may reset what activate() set; the lookup must
+ * repair that.
+ */
+ #[DataProvider('foreignChanges')]
+ public function testReactivateIfLostRepairsAForeignResetInAThreadSafePhpToo(\Closure $change): void
+ {
+ $this->activateBuild();
+ NativeGettext::useThreadSafeForTests(true);
+ NativeGettext::restore();
+ $this->assertTrue(NativeGettext::isActive(), 'precondition: nothing was taken back');
+ $change();
+ $this->assertFalse(NativeGettext::isActive(), 'precondition');
+
+ $this->assertTrue(NativeGettext::reactivateIfLost());
+
+ $this->assertSame('Hallo', NativeGettext::translate(self::DOMAIN, 'greeting'));
+ }
+
+ public function testReactivateIfLostFailsWithAProblemWhenTheLocaleIsGone(): void
+ {
+ $this->activateBuild();
+ setlocale(LC_MESSAGES, 'C');
+ MigratedPoFixture::removeDirectory($this->locale_directory . '/' . NativeGettext::LOCALE_NAME);
+
+ $this->assertFalse(NativeGettext::reactivateIfLost());
+
+ $this->assertStringContainsString('is missing', (string) NativeGettext::getProblem());
+ $this->assertFalse(NativeGettext::isActive());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function catalogs(): iterable
+ {
+ yield 'the catalog of the build' => ['build', true];
+ yield 'garbage instead of a catalog' => ['garbage', false];
+ yield 'a catalog without header (not one of ours)' => ['no-header', false];
+ yield 'a domain without file' => ['missing', false];
+ }
+
+ #[DataProvider('catalogs')]
+ public function testIsLoadedTellsAReadableCatalogOfOurOwnByItsHeader(string $kind, bool $expected): void
+ {
+ $this->activateBuild();
+ $directory = $this->build_directory;
+ $domain = self::DOMAIN;
+ if ($kind !== 'build') {
+ $domain = 'other.' . $kind;
+ $directory = $this->root . '/catalogs-' . $kind;
+ $file = $directory . '/' . NativeGettext::CATALOG_DIRECTORY . '/' . $domain . '.mo';
+ mkdir(dirname($file), 0775, true);
+ if ($kind === 'garbage') {
+ file_put_contents($file, "this is no .mo file\n");
+ } elseif ($kind === 'no-header') {
+ $catalog = new TranslationCatalog();
+ $entry = new TranslationEntry(null, 'greeting');
+ $entry->translate('Hallo');
+ $catalog->add($entry);
+ file_put_contents($file, $catalog->toMoString());
+ }
+ }
+ $this->assertTrue(NativeGettext::bind($domain, $directory));
+
+ $this->assertSame($expected, NativeGettext::isLoaded($domain));
+ }
+
+ public function testBindRefusesADirectoryThatDoesNotExist(): void
+ {
+ $this->assertFalse(NativeGettext::bind('ntg.xx', $this->root . '/does-not-exist'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function messageIdsThatCannotBeLookedUp(): iterable
+ {
+ yield 'empty (would answer with the header)' => [''];
+ yield 'longer than PHP accepts' => [str_repeat('x', 4097)];
+ yield 'unknown' => ['no_such_key'];
+ }
+
+ #[DataProvider('messageIdsThatCannotBeLookedUp')]
+ public function testTranslateAnswersTheMessageIdItself(string $message_id): void
+ {
+ $this->activateBuild();
+
+ $this->assertSame($message_id, NativeGettext::translate(self::DOMAIN, $message_id));
+ $this->assertSame($message_id, NativeGettext::translatePlural(self::DOMAIN, $message_id, 2));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function quantities(): iterable
+ {
+ yield 'one' => [1, 'Eintrag'];
+ yield 'zero' => [0, 'Einträge'];
+ yield 'minus one counts like one' => [-1, 'Eintrag'];
+ yield 'minus five counts like five' => [-5, 'Einträge'];
+ yield 'the largest integer' => [PHP_INT_MAX, 'Einträge'];
+ yield 'the smallest integer (abs() would overflow)' => [PHP_INT_MIN, 'Einträge'];
+ }
+
+ #[DataProvider('quantities')]
+ public function testTranslatePluralSelectsTheFormOfTheAbsoluteQuantity(int $n, string $expected): void
+ {
+ $this->activateBuild();
+
+ $this->assertSame(
+ $expected,
+ NativeGettext::translatePlural(self::DOMAIN, ShippedTranslations::PLURAL_CONTEXT . "\x04item", $n)
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/OverlayDirectoryOwnershipTest.php b/components/ILIAS/Language/tests/ComponentTranslation/OverlayDirectoryOwnershipTest.php
new file mode 100644
index 000000000000..0cfb68f4a8b5
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/OverlayDirectoryOwnershipTest.php
@@ -0,0 +1,252 @@
+markTestSkipped('Requires the POSIX extension (posix_getpwuid()/posix_getgrgid()).');
+ }
+
+ $this->fixture_directory = __DIR__ . '/tmp-ownership-fixtures-' . bin2hex(random_bytes(4));
+ mkdir($this->fixture_directory, 0775, true);
+ $this->client_data_dir = sys_get_temp_dir() . '/ilias_mlfs_ownership_test_' . bin2hex(random_bytes(4));
+ mkdir($this->client_data_dir, 0775, true);
+
+ $this->directory = MigratedPoFixture::directory(
+ self::MODULE,
+ 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/'
+ );
+ $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
+ MigratedPoFixture::writePo(
+ $this->fixture_directory . '/' . self::MODULE . '_de.po',
+ MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo'])
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_dir($this->overlayDirectory())) {
+ chmod($this->overlayDirectory(), 0775);
+ }
+ MigratedPoFixture::removeDirectory($this->fixture_directory);
+ MigratedPoFixture::removeDirectory($this->client_data_dir);
+
+ parent::tearDown();
+ }
+
+ private function overlayDirectory(): string
+ {
+ return dirname(MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, 'de'));
+ }
+
+ /**
+ * @return list
+ */
+ private function findUnwritable(?int $for_user_id): array
+ {
+ return MigratedLanguageFileSync::findUnwritableOverlayDirectories(
+ $this->manager,
+ ILIAS_ABSOLUTE_PATH,
+ ['de'],
+ $this->client_data_dir,
+ $for_user_id
+ );
+ }
+
+ /**
+ * Only the leaf overlay directory gets $mode; parents stay 0775, since a restrictive intermediate mode would break
+ * the test process's own traversal.
+ */
+ private function seedOverlayDirectory(int $mode): void
+ {
+ $overlay_directory = $this->overlayDirectory();
+ if (!is_dir(dirname($overlay_directory))) {
+ mkdir(dirname($overlay_directory), 0775, true);
+ }
+ if (!is_dir($overlay_directory)) {
+ mkdir($overlay_directory, 0775);
+ }
+ chmod($overlay_directory, $mode); // avoid mkdir()'s own mode being masked by umask
+ clearstatcache(true, $overlay_directory);
+ }
+
+ private function assertReported(int $mode, int $for_user_id, string $message): void
+ {
+ $this->seedOverlayDirectory($mode);
+
+ $this->assertSame([$this->overlayDirectory()], $this->findUnwritable($for_user_id), $message);
+ }
+
+ private function assertNotReported(int $mode, int $for_user_id, string $message): void
+ {
+ $this->seedOverlayDirectory($mode);
+
+ $this->assertSame([], $this->findUnwritable($for_user_id), $message);
+ }
+
+ /**
+ * Mutation: the `$for_user_id === 0` short-circuit being removed - root must be considered able
+ * to write regardless of mode (0000: nobody else could).
+ */
+ public function testRootMayAlwaysWriteRegardlessOfMode(): void
+ {
+ $this->assertNotReported(0000, 0, 'root (uid 0) must always be able to create/replace files');
+ }
+
+ /**
+ * Mutation: the owner branch using the wrong mask (e.g. read instead of write+search, or a
+ * group/other mask) - the directory's own owner needs the OWNER write+execute bits, group and
+ * other bits are irrelevant to them.
+ */
+ public function testTheOwnerNeedsTheOwnerWriteAndSearchBitsRegardlessOfGroupOrOtherBits(): void
+ {
+ if (posix_getuid() === 0) {
+ $this->markTestSkipped(
+ 'The current process is root, so its own uid would only hit the for_user_id===0 short-circuit '
+ . '(see testRootMayAlwaysWriteRegardlessOfMode()) instead of the owner branch under test; '
+ . 'testAnArbitraryChownedOwnerNeedsTheOwnerWriteAndSearchBits() covers the owner branch as root.'
+ );
+ }
+ // the directory is created by this process, so its owner is the current user, as fileowner() would report
+ $owner = posix_getuid();
+
+ $this->assertReported(
+ 0577, // owner: r-x (no write), group+other: rwx - irrelevant, the owner still can't write
+ $owner,
+ 'the owner lacking the write bit must be reported even though group/other have it'
+ );
+ $this->assertNotReported(
+ 0700, // owner: rwx, group+other: nothing
+ $owner,
+ 'the owner having the write+search bits must be enough on its own'
+ );
+ }
+
+ /**
+ * As above, with a directory chown()ed to a non-current uid - proves $stat['uid'] is evaluated against
+ * $for_user_id, not the process uid. Only possible as root.
+ */
+ public function testAnArbitraryChownedOwnerNeedsTheOwnerWriteAndSearchBits(): void
+ {
+ if (!function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ $this->markTestSkipped('Requires root to chown() the overlay directory to an arbitrary owner.');
+ }
+ $foreign_owner = 33; // www-data, present on both the reference host and the ILIAS container
+ $this->seedOverlayDirectory(0555);
+ chown($this->overlayDirectory(), $foreign_owner);
+ clearstatcache(true, $this->overlayDirectory());
+
+ $this->assertSame(
+ [$this->overlayDirectory()],
+ $this->findUnwritable($foreign_owner),
+ 'the chown()ed owner lacking the write bit must be reported'
+ );
+
+ chmod($this->overlayDirectory(), 0775);
+ clearstatcache(true, $this->overlayDirectory());
+
+ $this->assertSame(
+ [],
+ $this->findUnwritable($foreign_owner),
+ 'the chown()ed owner having the write+search bits must not be reported'
+ );
+ }
+
+ /**
+ * Mutation: the "other" branch being skipped, or using the owner/group mask instead - a uid that
+ * is neither the owner nor in its group is governed by the OTHER bits alone, however generous
+ * the owner/group bits are.
+ */
+ public function testAForeignUidNeedsTheOtherWriteAndSearchBitsEvenWhenOwnerAndGroupHaveThem(): void
+ {
+ $foreign_uid = 1; // "daemon", present on both the reference host and the ILIAS container
+
+ $this->assertReported(
+ 0775, // owner rwx, group rwx, other r-x (no write) - a stranger still can't write
+ $foreign_uid,
+ 'other lacking the write bit must be reported even though owner/group have it'
+ );
+ $this->assertNotReported(
+ 0777, // other now has rwx as well
+ $foreign_uid,
+ 'other having the write+search bits must be enough for a stranger'
+ );
+ }
+
+ /**
+ * Group branch: a uid that is not the owner but shares the directory's group (chgrp()'d to $for_user_id's primary
+ * gid) is governed by the GROUP write+search bits. Only possible as root.
+ */
+ public function testAGroupMemberNeedsTheGroupWriteAndSearchBitsRegardlessOfOwnerOrOtherBits(): void
+ {
+ if (!function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ $this->markTestSkipped('Requires root to chown()/chgrp() the overlay directory to an arbitrary owner/group.');
+ }
+ $group_member_uid = 33; // www-data, whose own primary gid (33) is also 33 on both the
+ // reference host and the ILIAS container - see posix_getpwuid(33) above.
+ $this->seedOverlayDirectory(0750); // owner: rwx, group: r-x (no write) - not enough
+ chown($this->overlayDirectory(), 0);
+ chgrp($this->overlayDirectory(), 33);
+ clearstatcache(true, $this->overlayDirectory());
+
+ $this->assertSame(
+ [$this->overlayDirectory()],
+ $this->findUnwritable($group_member_uid),
+ 'the group lacking the write bit must be reported even though the (different) owner has it'
+ );
+
+ chmod($this->overlayDirectory(), 0770); // group: rwx - now enough
+ clearstatcache(true, $this->overlayDirectory());
+
+ $this->assertSame(
+ [],
+ $this->findUnwritable($group_member_uid),
+ 'the group having the write+search bits must be enough for a group member who is not the owner'
+ );
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/PlainLogTextTest.php b/components/ILIAS/Language/tests/ComponentTranslation/PlainLogTextTest.php
new file mode 100644
index 000000000000..463e846bcea3
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/PlainLogTextTest.php
@@ -0,0 +1,90 @@
+assertSame('Hallo Welt, äöü', PlainLogText::of('Hallo Welt, äöü'));
+ }
+
+ public function testOfReplacesAnAnsiEscapeSequence(): void
+ {
+ $result = PlainLogText::of("bad\x1bkey");
+
+ $this->assertStringNotContainsString("\x1b", $result);
+ $this->assertSame('bad?key', $result);
+ }
+
+ public function testOfReplacesABidirectionalOverrideCharacter(): void
+ {
+ // U+202E RIGHT-TO-LEFT OVERRIDE - a format character (\p{Cf}), not a C0 control
+ $result = PlainLogText::of("bad\u{202E}key");
+
+ $this->assertStringNotContainsString("\u{202E}", $result);
+ $this->assertSame('bad?key', $result);
+ }
+
+ public function testOfReplacesEveryOccurrenceNotJustTheFirst(): void
+ {
+ $this->assertSame('a?b?c', PlainLogText::of("a\x01b\x02c"));
+ }
+
+ public function testKeyListJoinsUpToTheLimitWithoutTruncationMarker(): void
+ {
+ $keys = array_map(static fn(int $i): string => 'key' . $i, range(1, PlainLogText::MAX_LISTED_KEYS));
+
+ $result = PlainLogText::keyList($keys);
+
+ $this->assertSame(implode(', ', $keys), $result);
+ $this->assertStringNotContainsString('…', $result);
+ }
+
+ /** One key over the limit: the boundary where truncation starts. */
+ public function testKeyListTruncatesAtOneOverTheLimit(): void
+ {
+ $keys = array_map(static fn(int $i): string => 'key' . $i, range(1, PlainLogText::MAX_LISTED_KEYS + 1));
+ $expected_listed = implode(', ', array_slice($keys, 0, PlainLogText::MAX_LISTED_KEYS));
+
+ $result = PlainLogText::keyList($keys);
+
+ $this->assertSame($expected_listed . ' … (+1)', $result);
+ }
+
+ public function testKeyListReportsTheExactCountLeftOut(): void
+ {
+ $keys = array_map(static fn(int $i): string => 'key' . $i, range(1, PlainLogText::MAX_LISTED_KEYS + 5));
+
+ $this->assertStringContainsString('… (+5)', PlainLogText::keyList($keys));
+ }
+
+ public function testKeyListWithNoKeysIsAnEmptyString(): void
+ {
+ $this->assertSame('', PlainLogText::keyList([]));
+ }
+}
diff --git a/components/ILIAS/Language/tests/ComponentTranslation/PluralFormKeyTest.php b/components/ILIAS/Language/tests/ComponentTranslation/PluralFormKeyTest.php
new file mode 100644
index 000000000000..f654102ce010
--- /dev/null
+++ b/components/ILIAS/Language/tests/ComponentTranslation/PluralFormKeyTest.php
@@ -0,0 +1,91 @@
+ [