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/File/File.php b/components/ILIAS/File/File.php index 6d44f47a72dc..c33340f2e693 100644 --- a/components/ILIAS/File/File.php +++ b/components/ILIAS/File/File.php @@ -46,6 +46,8 @@ use ILIAS\FileUpload\Activities\Parameter; use ILIAS\Setup\Agent; use ILIAS\Refinery\Factory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; class File implements Component { @@ -195,5 +197,10 @@ public function init( $contribute[Activity::class] = static fn() => $internal[ListFileVersions::class]; $contribute[Activity::class] = static fn() => $internal[DeliverFileVersion::class]; $contribute[Activity::class] = static fn() => $internal[DownloadContainerAsZip::class]; + + $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $this, + 'file' + ); } } diff --git a/components/ILIAS/File/lang/ilias_de.lang b/components/ILIAS/File/lang/ilias_de.lang new file mode 100644 index 000000000000..1027e6cec5fd --- /dev/null +++ b/components/ILIAS/File/lang/ilias_de.lang @@ -0,0 +1,115 @@ +/** +* This file is part of ILIAS, a powerful learning management system +* published by ILIAS open source e-Learning e.V. +* +* ILIAS is licensed with the GPL-3.0, +* see https://www.gnu.org/licenses/gpl-3.0.en.html +* You should have received a copy of said license along with the +* source code, too. +* +* If this is not the case or you just want to try ILIAS, you'll find +* us at: +* https://www.ilias.de +* https://github.com/ILIAS-eLearning +* +******************************************************************** +*/ +/** +* ILIAS language file +* +* @module language file German +* @modulegroup language +* @author Matthias Kunkel +* @version $Id$ +*/ +// The language file starts beyond the HTML-comment below. DO NOT modify this line! +// To edit your language file with a spreadsheet (i.e. Excel or StarCalc) remove all lines +// from the first line to the HTML-comment. After editing paste the lines in place again. +// NOTICE: Character coding of all ILIAS lang files is UTF-8! Please set your editor +// to the corresponding mode! +// Language file names refer to ISO 639, see: http://www.oasis-open.org/cover/iso639a.html + +add_icon#:#Icon hinzufügen +amount_of_downloads#:#Downloads +amount_of_downloads_since#:#%d seit %s +copyright_custom#:#Benutzerdefiniert +copyright_custom_info#:#Lizenz auswählen, welche auf alle entpackten Dateien dieses Archivs angewandt wird. +copyright_inherited#:#Vererbt +copyright_inherited_info#:#Lizenz des Zip-Archivs auf seine entpackten Dateien anwenden.
Lizenz des Zip-Archivs: %s. +could_not_create_file_objs#:#Beim Erstellen der Dateiobjekte ist ein Fehler aufgetreten. Bitte wenden Sie sich an die technische Betreuung dieser Installation. +de_activate_icon#:#(De-)Aktivieren +download_ascii_filename#:#Nur ASCII-Zeichen im Download +download_ascii_filename_info#:#Die Namen heruntergeladener Dateien enthalten nur ASCII-Zeichen und können so auf allen Systemen problemlos genutzt werden. Andere Zeichen werden durch Unterstriche ersetzt. Auf Installationen, die Dateinamen mit nicht-lateinischen Schriftzeichen nutzen, muss diese Option deaktiviert werden. +file_action_download#:#Datei herunterladen oder Inhalt anzeigen +file_action_download_info#:#Wenn WOPI aktiviert ist und die Berechtigung „Inhalt anzeigen“ erteilt wurde, wird die Datei auf der Registerkarte „Inhalt“ angezeigt. Wenn nicht, wird die Datei zum direkten Download angeboten. +file_action_show#:#Zunächst Reiter „Info“ anzeigen +file_action_show_info#:#Der Reiter „Info“ wird angezeigt, wo die Datei in einem zweiten Schritt heruntergeladen werden kann. +file_btn_lp_toggle_state_completed#:#Auf „Nicht bearbeitet“ setzen +file_btn_lp_toggle_state_not_completed#:#Auf „Bearbeitet“ setzen +file_copy#:#Datei kopieren +file_download#:#Datei herunterladen +file_import#:#Dateiimport +file_new_version#:#Neue Version anlegen +file_new_version_info#:#Eine neue Dateiversion anlegen. Alte Versionen bleiben erhalten. +file_publish#:#Entwurf veröffentlichen +file_rollback_rollback_first#:#Die Version konnte nicht zurückgesetzt werden, weil ein unveröffentlichter Entwurf existiert. +file_rollback_same_version#:#Dies ist bereits die veröffentlichte Version +file_unpublish#:#Als Entwurf markieren +file_upload_info_file_with_critical_extension#:#Mindestens eine Datei enthält eine kritische oder unbekannte Dateiendung. Die Dateiendung wird bei jedem Download zu .sec geändert. Bitte kontaktieren Sie gegebenenfalls Ihre technische Betreuung. Dateiname(n): %s +file_uploaded_by#:#Hochgeladen von +file_version_draft#:#Entwurf +file_version_draft_info#:#Die aktuellste Version ist im Status „Entwurf“. Solange diese Version nicht veröffentlicht wurde, können keine neuen Versionen angelegt werden. Personen mit Leserecht auf die Datei erhalten die aktuell veröffentlichte Version. +form_icon_creation#:#Icon erstellen +form_icon_updating#:#Icon aktualisieren +general_upload_error_occured#:#Beim Verarbeiten der Datei ist ein unerwartetet Fehler aufgetreten. +important_info#:#Wichtige Informationen +important_info_byline#:#Die Angaben werden im Reiter „Info“ angezeigt. +input_active#:#Aktiv +input_desc_active#:#Dieses Icon aktivieren. +input_desc_icon#:#Bild welches als Icon für Dateien mit den spezifizierten Suffixen verwendet werden soll. +input_desc_suffixes#:#Liste Komma-separierter Suffixe (Nur Buchstaben und Zahlen ohne vorangehenden Punkt). +input_icon#:#Icon +input_suffixes#:#Suffix(e) +migrated#:#Status +mime_type#:#MIME-Type +msg_cant_unpublish#:#Aktion konnte nicht ausgeführt werden +msg_confirm_entry_deletion#:#Wollen Sie den nachfolgenden Eintrag wirklich löschen?: +msg_error_active_suffixes_blacklisted#:#Eine der gewählten Dateiendungen ist auf der globalen Blacklist und kann derzeit nicht genutzt werden. +msg_error_active_suffixes_conflict#:#Fehler: Es dürfen nicht mehrere Icons für ein Suffix aktiv sein. Bitte deaktivieren Sie entweder dieses Icon oder das andere aktivierte Icon, dessen Suffixe sich mit jenen dieses Icons überschneiden. +msg_error_active_suffixes_not_whitelisted#:#Die gewählte Dateiendung ist nicht auf der globalen Whitelist, beim Download werden die Dateine zu .sec. umbenannt. +msg_error_duplicate_suffix_entries#:#Fehler: Die Komma-separierte Suffix-Liste enthält Duplikate. +msg_error_icon_deletion#:#Fehler: Icon Löschung fehlgeschlagen. +msg_error_suffixes_with_forbidden_characters#:#Fehler: unerlaubte Zeichen. Nur Buchstaben, Nummern, Leerzeichen und Kommas sind erlaubt. +msg_icon_missing_from_db#:#Icon ist nicht in der Datenbank. +msg_icon_missing_from_irss#:#Icon ist nicht im Resource Storage. +msg_success_icon_activated#:#Icon erfolgreich aktiviert. +msg_success_icon_created#:#Icon erfolgreich erstellt. +msg_success_icon_deactivated#:#Icon erfolgreich deaktiviert. +msg_success_icon_deletion#:#Icon erfolgreich gelöscht. +msg_success_icon_updated#:#Icon erfolgreich aktualisiert. +msg_unzip_success#:#Archiv wurde erfolreich entpackt. +on_click_action#:#Klick auf Titel +preview_caption#:#Vorschau %svon %s +preview_image_size_info#:#Die Vorschauversionen der Bilder werden je nach Bedarf verkleinert oder vergrößert, so dass ihre längste Seite der hier eingegebenen Länge (in px) entspricht. +preview_persisting#:#Vorschaubilder speichern +preview_persisting_info#:#Vorschaubilder werden generiert und gespeichert. Sie müssen nicht mehr bei jedem Aufruf neu erzeugt werden. Das verbessert die Performanz. +previews_for_tiles#:#Kachelbilder +previews_for_tiles_info#:#Verwende Vorschaubilder für Kacheln. +publish_before_delete#:#Version(en) konnte(n) nicht gelöscht werden, da ein noch unveröffentlichter Entwurf existiert. +replace_file_info#:#Alle früheren Dateiversionen werden gelöscht. +resource_id#:#Ressourcen ID +service_settings#:#Weitere Funktionen +service_settings_saved#:#Gespeichert +set_license_for_all_files#:#Lizenz für alle Dateien festlegen +show_amount_of_downloads#:#Anzahl Downloads anzeigen +show_amount_of_downloads_info#:#Die Anzahl der Downloads wird im Reiter „Info“ des Dateiobjekts angezeigt. +storage_id#:#Storage ID +suffix_specific_icons#:#Suffix-spezifische Icons +suffixes#:#Suffix(e) +upload_files#:#Dateien hochladen +upload_files_limit#:#Die maximale Größe einer Datei beträgt %s. +upload_files_title#:#Dateien hochladen +upload_info#:#Datei +upload_info_desc#:#Sie können die Dateien und die Dateiversionen im Tab "Versionen" verwalten. +version_uploaded#:#Version hochgeladen am +versionname#:#Angezeigter Titel \ No newline at end of file diff --git a/components/ILIAS/File/lang/ilias_en.lang b/components/ILIAS/File/lang/ilias_en.lang new file mode 100644 index 000000000000..ef445b44660d --- /dev/null +++ b/components/ILIAS/File/lang/ilias_en.lang @@ -0,0 +1,115 @@ +/** +* This file is part of ILIAS, a powerful learning management system +* published by ILIAS open source e-Learning e.V. +* +* ILIAS is licensed with the GPL-3.0, +* see https://www.gnu.org/licenses/gpl-3.0.en.html +* You should have received a copy of said license along with the +* source code, too. +* +* If this is not the case or you just want to try ILIAS, you'll find +* us at: +* https://www.ilias.de +* https://github.com/ILIAS-eLearning +* +******************************************************************** +*/ +/** +* ILIAS language file +* +* @module language file English +* @modulegroup language +* @author Matthias Kunkel , Chris Potter +* @version $Id$ +*/ +// The language file starts beyond the HTML-comment below. DO NOT modify this line! +// To edit your language file with a spreadsheet (i.e. Excel or StarCalc) remove all lines +// from the first line to the HTML-comment. After editing paste the lines in place again. +// NOTICE: Character coding of all ILIAS lang files is UTF-8! Please set your editor +// to the corresponding mode! +// Language file names refer to ISO 639, see: http://www.oasis-open.org/cover/iso639a.html + +add_icon#:#Add Icon +amount_of_downloads#:#Downloads +amount_of_downloads_since#:#%d times since %s +copyright_custom#:#Custom +copyright_custom_info#:#Choose a custom copyright which will be applied to all unzipped files of this archive. +copyright_inherited#:#Inherited +copyright_inherited_info#:#Apply the copyright of the zip archive to its unzipped files.
Copyright of zip archive: %s. +could_not_create_file_objs#:#An error occurred while creating your file objects. Please contact this installation's technical support. +de_activate_icon#:#Activate / Deactivate +download_ascii_filename#:#Allow Only ASCII Characters in Downloaded Filenames +download_ascii_filename_info#:#Downloaded files should only have ASCII-characters in their filename. Deactivate to use all characters. +file_action_download#:#Download file or view content +file_action_download_info#:#If WOPI is activated and the permission 'View Content' is given, the file is shown in tab 'Content'. If not, the file will be offered for direct download. +file_action_show#:#Redirect user to the file’s ‘Info’ tab +file_action_show_info#:#The 'Info' tab is shown where the file could be downloaded in a second step. +file_btn_lp_toggle_state_completed#:#Set Not Completed +file_btn_lp_toggle_state_not_completed#:#Set Completed +file_copy#:#Copy File +file_download#:#Download File +file_import#:#Import File +file_new_version#:#Create New Version +file_new_version_info#:#Create new file version. Previous versions will not be modified. +file_publish#:#Publish Draft +file_rollback_rollback_first#:#The selected version could not be published because an unpublished draft exists. +file_rollback_same_version#:#This is already the published version! +file_unpublish#:#Mark as Draft +file_upload_info_file_with_critical_extension#:#At least one uploaded file contains a critical or unknown file ending. Whenever the file is downloaded, its ending will be changed to ‘.sec’. If necessary, contact your administrator. Filename(s): %s +file_uploaded_by#:#Uploaded By +file_version_draft#:#Draft Version +file_version_draft_info#:#The latest version of this file has the status ‘Draft’. As long as this version has not been published, no new versions can be created. People with read permission for the file get the most recent previously published version. +form_icon_creation#:#Create Icon +form_icon_updating#:#Update Icon +general_upload_error_occured#:#An unexpected error occurred during upload. +important_info#:#Important Information +important_info_byline#:#The information will be displayed in the ‘Info’ tab. +input_active#:#Active +input_desc_active#:#Activate this icon. +input_desc_icon#:#Image to be used as the icon for files with the specified suffixes. +input_desc_suffixes#:#List of comma-separated suffixes (characters and numbers only, without preceding full stop). +input_icon#:#Icon +input_suffixes#:#Suffix(es) +migrated#:#Status +mime_type#:#MIME Type +msg_cant_unpublish#:#File could not be unpublished. +msg_confirm_entry_deletion#:#Are you sure you want to delete the following entry?: +msg_error_active_suffixes_blacklisted#:#One of the selected file extensions is on the global blacklist and cannot therefore be currently used. +msg_error_active_suffixes_conflict#:#Error: It is not possible to have multiple icons activated for the same suffix. Please deactivate either this icon or the other activated icon whose suffixes overlap with those of this icon. +msg_error_active_suffixes_not_whitelisted#:#The selected file extension is not on the global whitelist. The file suffixes will be changed to ‘.sec’ when downloaded. +msg_error_duplicate_suffix_entries#:#Error: the comma separated list of suffixes contains duplicate entries. +msg_error_icon_deletion#:#Error: icon deletion failed. +msg_error_suffixes_with_forbidden_characters#:#Error: forbidden characters. Only letters, numbers, spaces and commas are allowed. +msg_icon_missing_from_db#:#Icon missing from database. +msg_icon_missing_from_irss#:#Icon missing from resource storage. +msg_success_icon_activated#:#Icon successfully activated. +msg_success_icon_created#:#Icon successfully created. +msg_success_icon_deactivated#:#Icon successfully deactivated. +msg_success_icon_deletion#:#Icon successfully deleted. +msg_success_icon_updated#:#Icon successfully updated. +msg_unzip_success#:#Archive successfully unzipped. +on_click_action#:#Action When Title Clicked +preview_caption#:#Preview %sof %s +preview_image_size_info#:#The preview versions of images will be downscaled or upscaled as appropriate, so that their longest side is the length (in px) entered here. +preview_persisting#:#Persistent Preview Images +preview_persisting_info#:#Generated preview images will be stored by ILIAS and used from then on each time the preview icon for that file is clicked on. If deactivated, previews will be generated anew each time. +previews_for_tiles#:#Tiles +previews_for_tiles_info#:#Use Preview for Tiles where possible. +publish_before_delete#:#It was not possible to delete any of the existing versions because an unpublished draft exists. +replace_file_info#:#All previous file versions will be deleted. +resource_id#:#Resource ID +service_settings#:#Additional Features +service_settings_saved#:#Changes saved. +set_license_for_all_files#:#Set License for All Files +show_amount_of_downloads#:#Show Number of Downloads +show_amount_of_downloads_info#:#Display the number of times a file object has been downloaded on its 'Info' page. +storage_id#:#Storage ID +suffix_specific_icons#:#Suffix-Specific Icons +suffixes#:#Suffix(es) +upload_files#:#Upload Files +upload_files_limit#:#The maximum file size allowed is %s. +upload_files_title#:#Upload Files +upload_info#:#File +upload_info_desc#:#Uploads and versions can be managed in the ‘Versions’ tab. +version_uploaded#:#Version Uploaded +versionname#:#Title \ No newline at end of file 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..896c37f532d0 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -20,6 +20,21 @@ 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\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; + class Language implements Component\Component { public function init( @@ -34,23 +49,144 @@ 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(); + + $runtime_setup_language = static fn(): \ilSetupLanguage => + new \ilSetupLanguage( + "en", + $internal[LanguageFileDirectoryManager::class], + $pull[\ILIAS\Database\Connection::class], + $internal[Language\Setup\TranslationCache::class] + ); + + $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] + ); + + // runtime-only Activities route all legacy access through this provider + $internal[Language\Activities\LanguageSettings::class] = static fn() => + new Language\Activities\LanguageSettingsLegacyProxy(); + + $internal[RepositoryProvider::class] = static fn() => + new RepositoryProvider( + $internal[InstalledLanguageDatabaseRepository::class], + $pull[\ILIAS\Database\Connection::class], + $internal[Language\Activities\LanguageSettings::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]), + 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(); diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 9a60a158bb43..b3ddd271e779 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -6,22 +6,122 @@ 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. + + $language->loadLanguageModule("frm"); + $tpl->setVariable("TEXT", $language->txt("frm_new_posting")); + +## 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. + +## 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. +* **UpdateLanguage** (`src/Activities/UpdateLanguage.php`) - refreshes one or more already + installed languages from the current language files. +* **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. ## 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: +Only a defined set of HTML tags are allowed to be used within the `text_content` of a language entry. +The allow-list is passed explicitly to `ilUtil::stripSlashes()` at the only place this component +applies it - the language file import in `ilObjLanguageExtGUI::importLangfile()` +(`classes/class.ilObjLanguageExtGUI.php`) - and currently is: -* 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` +* `a`, `b`, `bdo`, `br`, `code`, `div`, `em`, `gap`, `i`, `img`, `li`, `ol`, `p`, `pre`, `span`, + `strike`, `strong`, `sup` and `ul` + +This is almost, but not exactly, the tag set returned by `ilUtil::getSecureTags()` plus `span` and +`br`: `sub` is part of `getSecureTags()` but is missing from the allow-list actually passed here. +Whether that omission is intentional is unclear; treat `sub` as unsupported in language files until +this is resolved one way or the other. All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes`. + +## 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..d707706e1c45 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -4,17 +4,35 @@ * 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 ## 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()` ## 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..2690522210de 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php @@ -21,6 +21,8 @@ use ILIAS\Setup; use ILIAS\Refinery; use ILIAS\UI; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; class ilLanguageSetupAgent implements Setup\Agent { @@ -28,13 +30,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 +69,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,7 +86,11 @@ 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 + ), ); } diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php index f85796340a43..e1afafc51f6e 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php @@ -19,16 +19,30 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; 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); } /** @@ -44,15 +58,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 +66,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); } /** @@ -86,20 +96,9 @@ public function getPreconditions(Setup\Environment $environment): array */ 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->getInstallLanguages(), - $this->getInstallLocalLanguages() - ); - - $GLOBALS["ilDB"] = $db_tmp; + // Must come first: getInstallLanguages() reads from the database too. + $this->useSetupDatabase($environment); + $this->installLanguages($this->getInstallLanguages()); return $environment; } @@ -109,6 +108,53 @@ 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; } + + /** + * Hands the Setup-provided database resource to ilSetupLanguage, which + * is the single place resolving it for everything that follows: the + * Activity needs no database of its own, and ilSetupLanguage's + * repository and manager re-read it on every call. + * + * achieve() must call this before reading anything from + * ilSetupLanguage, so that the language list is determined against the + * Setup database too. Overwriting $GLOBALS['ilDB'] around the install - + * which this used to do, with a TODO attached - is no longer necessary. + */ + protected function useSetupDatabase(Setup\Environment $environment): void + { + $this->il_setup_language->setDbHandler( + $environment->getResource(Setup\Environment::RESOURCE_DATABASE) + ); + } + + /** + * Installs missing languages and refreshes installed ones on every run, so that changed language files take effect. + * + * Each Activity validates and throws \RuntimeException only for the + * keys it actually processes (see InstallLanguage::perform() and + * UpdateLanguage::perform()); a validation error in the first call + * prevents the second from running at all, instead of collecting + * invalid keys from both into a single combined error. + * + * @param list $language_keys + */ + protected function installLanguages(array $language_keys): void + { + $this->install_language->perform([ + 'language_keys' => $language_keys, + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->update_language->perform([ + 'language_keys' => $language_keys, + ]); + } } 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..b4da0e482969 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -18,6 +18,17 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +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; + /** * language handling for setup * @@ -32,29 +43,71 @@ * @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; - public function __construct(string $a_lang_key) - { + /** + * $connection is the ILIAS\Database\Connection; callers outside the + * component graph pass none. + */ + public function __construct( + string $a_lang_key, + private ?LanguageFileDirectoryManager $language_file_directory_manager = null, + private readonly ?ilDBInterface $connection = null, + private readonly TranslationCache $translation_cache = new NullTranslationCache(), + ) { $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->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) + ); + } + + private function manager(): LanguageInstallationManager + { + return new LanguageInstallationManager( + $this->db() ?? throw new LogicException("No database available for " . self::class . "."), + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository(), + null, + $this->translation_cache + ); } /** @@ -97,254 +150,98 @@ 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(); - } - - if (empty($a_local_keys)) { - $a_local_keys = array(); + $a_lang_keys = []; } - $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; - } - } - - 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); - } - } - } - } - - return ($err_lang) ?: true; + return $this->manager()->installLanguages($a_lang_keys, $a_local_keys); } /** - * get already installed languages (in db) + * 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 getInstalledLanguages(): array - { - global $ilDB; + public function registerInstalledLanguage( + string $lang_key, + array $known_languages, + array $local_language_keys + ): void { + $this->manager()->registerInstalledLanguage($lang_key, $known_languages, $local_language_keys); + } - $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); + public function getAvailableLanguagesForInstallation(): array + { + return $this->repository()->getAvailableLanguages(); + } - 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); + } + + public function flushLanguageForInstallation(string $lang_key): void + { + $this->manager()->flushLanguageForInstallation($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 flushLanguageForUninstallation(string $lang_key): void + { + $this->manager()->flushLanguageForUninstallation($lang_key); + } - while ($row = $ilDB->fetchObject($r)) { - $arr[] = $row->title; - } - } - return $arr; + public function insertLanguageForInstallation(string $lang_key): void + { + $this->manager()->insertLanguageForInstallation($lang_key); } /** - * get already registered languages (in db) + * See LanguageInstallationManager::insertLanguageForApplyingLocalChanges(). */ - protected function getAvailableLanguages(): array + public function insertLanguageForApplyingLocalChanges(string $lang_key): void { - 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; + $this->manager()->insertLanguageForApplyingLocalChanges($lang_key); } /** - * 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 + * get already installed languages (in db) */ - protected function checkLanguage(string $a_lang_key, string $scope = ""): bool + public function getInstalledLanguages(): 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); - - // compute lang-file name format - $lang_file = "ilias_" . $a_lang_key . ".lang" . $scopeExtension; - - // file check - if (!is_file($lang_file)) { - chdir($tmpPath); - return false; - } - - // header check - if (!$content = $this->cut_header(file($lang_file))) { - chdir($tmpPath); - return false; - } - - // 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; - } - } - - chdir($tmpPath); - - // no error occured - return true; + return $this->repository()->getInstalledLanguages(); } /** - * Remove *.lang header information from '$content'. - * - * This function seeks for a special keyword where the language information starts. - * If found it returns the plain language information; otherwise returns false. - * - * $content expect an ILIAS lang-file - * - * @return bool|string[] + * get already installed local languages (in db) */ - protected function cut_header(array $content) + public function getInstalledLocalLanguages(): array { - foreach ($content as $key => $val) { - if (trim($val) === "") { - return array_slice($content, $key + 1); - } - } - return false; + return $this->repository()->getInstalledLocalLanguages(); } /** - * remove language data from database - * $a_lang_key language key - * $a_mode "all" or "keep_local" + * 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 flushLanguage(string $a_lang_key, string $a_mode = "all"): void + protected function checkLanguage(string $a_lang_key): bool { - global $ilDB; - - self::_deleteLangData($a_lang_key, ($a_mode === "keep_local")); - - if ($a_mode === "all") { - $ilDB->manipulate("DELETE FROM lng_modules WHERE lang_key = " . - $ilDB->quote($a_lang_key, "text")); - } + return $this->repository()->checkLanguage($a_lang_key); } /** @@ -375,204 +272,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 +300,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.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index 5275ee22bc92..a34a0c011d6e 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -255,7 +255,7 @@ public function loadLanguageModule(string $a_module): void return; } - $q = "SELECT * FROM lng_modules " . + $q = "SELECT lang_array FROM lng_modules " . "WHERE lang_key = " . $ilDB->quote($lang_key, "text") . " AND module = " . $ilDB->quote($a_module, "text"); $r = $ilDB->query($q); @@ -287,6 +287,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 { @@ -308,7 +311,7 @@ public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a $ilDB = $DIC->database(); $set = $ilDB->query($q = sprintf( - "SELECT * FROM lng_data WHERE module = %s " . + "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"), diff --git a/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php b/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php index 544677f56c0f..85ee64a9ac15 100644 --- a/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php +++ b/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php @@ -186,24 +186,25 @@ public function getRows( } $record['users'] = ilObjLanguage::countUsers($record["key"]); + $last_local_change = ''; if ($record["desc"] !== "not_installed") { if ($this->access->checkAccess('write', '', $this->folder->getId())) { $record['language'] = $record['language']->withDisabled(false); } $record['last_refresh'] = ilDatePresentation::formatDate(new ilDateTime($record["last_update"], IL_CAL_DATETIME, 'UTC')); - $last_change = ilObjLanguage::_getLastLocalChange($record["key"]); - $record['last_change'] = ilDatePresentation::formatDate(new ilDateTime($last_change, IL_CAL_DATETIME, 'UTC')); + $last_local_change = ilObjLanguage::_getLastLocalChange($record["key"]); + $record['last_change'] = ilDatePresentation::formatDate(new ilDateTime($last_local_change, IL_CAL_DATETIME, 'UTC')); } yield $row_builder->buildDataRow($obj_id, $record) + ->withDisabledAction('install', ($record['desc'] !== 'not_installed')) + ->withDisabledAction('install_local', !str_starts_with($record['desc'], 'installed')) ->withDisabledAction('setSystemLanguage', ($record['desc'] === 'not_installed') || ($record['key'] === $this->lng->getDefaultLanguage())) ->withDisabledAction('setUserLanguage', ($record['desc'] === 'not_installed') || ($record['key'] === $this->lng->getUserLanguage())) ->withDisabledAction('refresh', ($record['desc'] === 'not_installed')) ->withDisabledAction('uninstall', ($record['desc'] === 'not_installed')) - ->withDisabledAction('lang_uninstall_changes', ($record['desc'] === 'not_installed')) - ->withDisabledAction('install', ($record['desc'] !== 'not_installed')) - ->withDisabledAction('install_local', ($record['desc'] !== 'not_installed')); + ->withDisabledAction('lang_uninstall_changes', !str_starts_with($record['desc'], 'installed') || $last_local_change === ''); } } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index eaac24092f6b..c2aecee3dcc1 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -18,6 +18,15 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\Setup\InstalledLanguageRepository; +use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCacheLegacyProxy; +use ILIAS\Language\Setup\FixedInstallationRoot; + /** * Class ilObjLanguage * @@ -40,6 +49,10 @@ class ilObjLanguage extends ilObject public string $key; public string $status; public string $cust_lang_path; + public string $absolute_path; + private LanguageFileDirectoryManager $language_file_directory_manager; + private InstalledLanguageRepository $repository; + private LanguageInstallationManager $manager; /** * Constructor @@ -47,11 +60,19 @@ class ilObjLanguage extends ilObject * $a_id reference_id or object_id * $a_call_by_reference treat the id as reference_id (true) or object_id (false) */ - public function __construct(int $a_id = 0, bool $a_call_by_reference = false) - { + public function __construct( + int $a_id = 0, + bool $a_call_by_reference = false, + ?LanguageFileDirectoryManager $language_file_directory_manager = null + ) { global $DIC; $lng = $DIC->language(); + // the first constructor argument is the customizing directory, not a global one + $this->language_file_directory_manager = $language_file_directory_manager + ?? ($DIC[LanguageFileDirectoryManager::class] ?? null) + ?? new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()); + $this->type = "lng"; parent::__construct($a_id, $a_call_by_reference); @@ -64,6 +85,21 @@ public function __construct(int $a_id = 0, bool $a_call_by_reference = false) $this->cust_lang_path = $lng->getCustomLangPath(); $this->separator = $lng->separator; $this->comment_separator = $lng->comment_separator; + $this->absolute_path = (string) realpath(__DIR__ . "/../../../../"); + + $this->repository = new InstalledLanguageDatabaseRepository( + $DIC->database(), + $this->language_file_directory_manager, + new FixedInstallationRoot($this->absolute_path) + ); + $this->manager = new LanguageInstallationManager( + $DIC->database(), + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository, + null, + new TranslationCacheLegacyProxy() + ); } @@ -89,6 +125,9 @@ public static function getInstalledLanguages(): array /** * Return the language keys of the installed languages * + * Matches only "installed", not "installed_local" (unlike ilLanguage::_getInstalledLanguages()); this affects + * ilPluginLanguage's language selection. + * * @return array */ public static function getLangKeysOfInstalledLanguages(): array @@ -184,12 +223,8 @@ public function isLocal(): bool */ public function install(string $scope = ""): string { - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } + if ($scope === "global") { + $scope = ""; } if (!$this->isInstalled() || (!$this->isLocal() && !empty($scope))) { @@ -240,6 +275,8 @@ public function uninstall(): string /** * refresh current language + * + * A single insert() covers global and local/customizing content. */ public function refresh(): bool { @@ -250,13 +287,6 @@ public function refresh(): bool $this->setDescription($this->getStatus()); $this->update(); - if ($this->isLocal() && $this->check("local")) { - $this->insert("local"); - $this->setTitle($this->getKey()); - $this->setDescription($this->getStatus()); - $this->update(); - } - return true; } @@ -264,8 +294,11 @@ public function refresh(): bool } /** - * Refresh all installed languages - */ + * Refresh all installed languages. + * + * @deprecated no caller left in the core (ilObjLanguageFolderGUI::refreshObject() was unreachable and removed; + * refreshSelectedObject() uses \ILIAS\Language\Activities\UpdateLanguage). Kept as public API for external code. + */ public static function refreshAll(): void { $languages = ilObject::_getObjectsByType("lng"); @@ -358,7 +391,7 @@ public function getLocalChanges(string $a_min_date = "", string $a_max_date = "" } $q = sprintf( - "SELECT * FROM lng_data WHERE lang_key = %s " . + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s " . "AND local_change >= %s AND local_change <= %s", $ilDB->quote($this->key, "text"), $ilDB->quote($a_min_date, "timestamp"), @@ -412,7 +445,7 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) $changes = array(); $result = $ilDB->queryF( - "SELECT * FROM lng_data WHERE lang_key = %s AND module = %s AND local_change IS NOT NULL", + "SELECT identifier, value FROM lng_data WHERE lang_key = %s AND module = %s AND local_change IS NOT NULL", array("text", "text"), array($a_key, $a_module) ); @@ -427,46 +460,33 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) /** * insert language data from file into database * - * $scope empty (global) or "local" + * @deprecated $scope has no effect: LanguageInstallationManager::insertLanguageForInstallation() always processes + * every directory, including customizing, in one idempotent pass. No caller in the core passes it; kept for external code. */ public function insert(string $scope = ""): void { - global $DIC; - $ilDB = $DIC->database(); - $scopeExtension = ""; - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } - } + $this->manager->insertLanguageForInstallation($this->key); + } - $path = $this->lang_path; - if ($scope === "local") { - $path = $this->cust_lang_path; + /** + * Reinstalls the language purely from the global/component files. Must not use insert(): that merges the + * customizing directory back in and would reinstate what this method removes. + * + * Return true if the language was installed and could be reinstalled + */ + public function removeLocalChanges(): bool + { + if (!$this->isInstalled() || !$this->check()) { + return false; } - $lang_file = $path . "/ilias_" . $this->key . ".lang" . $scopeExtension; + $this->flush("all"); + $this->manager->insertLanguageForRemovingLocalChanges($this->key); + $this->setTitle($this->getKey()); + $this->setDescription("installed"); + $this->update(); - if (is_file($lang_file)) { - // remove header first - if ($content = self::cut_header(file($lang_file))) { - $local_changes = null; - if (empty($scope)) { - // get all local changes for a global file - $local_changes = $this->getLocalChanges(); - } elseif ($scope === "local") { - // get the modification date of the local file - // get the newer local changes for a local file - $min_date = gmdate("Y-m-d H:i:s", filemtime($lang_file)); - $local_changes = $this->getLocalChanges($min_date); - } - $dbAccess = new ilObjLanguageDBAccess($ilDB, $this->key, $content, $local_changes, $scope); - $lang_array = $dbAccess->insertLangEntries($lang_file); - $dbAccess->replaceLangModules($lang_array); - } - } + return true; } /** @@ -682,94 +702,18 @@ public function optimizeData(): bool * header, and each lang-entry consists of exactly three elements * (module, identifier, value). * - * $scope empty (global) or "local" - * Return system message + * $scope empty/"global" (all managed directories) or "local" + * (customizing directory only) + * + * Delegates to InstalledLanguageRepository; callers (e.g. ilObjLanguageFolderGUI) turn a `false` into user feedback. */ public function check(string $scope = ""): bool { - global $DIC; - $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(); - - // dir check - if (!is_dir($path)) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Directory not found: " . $path, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); + return $this->repository->checkLocalLanguageFile($this->key); } - chdir($path); - - // compute lang-file name format - $lang_file = "ilias_" . $this->key . ".lang" . $scopeExtension; - - // file check - if (!is_file($lang_file)) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "File not found: " . $lang_file, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - - // header check - $content = self::cut_header(file($lang_file)); - if ($content === false) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Wrong Header in " . $lang_file, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - - // check (counting) elements of each lang-entry - $line = 0; - $n = 0; - foreach ($content as $key => $val) { - $separated = explode($this->separator, trim($val)); - $num = count($separated); - ++$n; - if ($num !== 3) { - $line = $n + 36; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Wrong parameter count in " . $lang_file . " in line $line (Value: $val)! Please check your language file!", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - if (!ilStr::isUtf8($separated[2])) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Non UTF8 character found in " . $lang_file . " in line $line (Value: $val)! Please check your language file!", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - } - - chdir($tmpPath); - - // no error occured - return true; + return $this->repository->checkLanguage($this->key); } /** diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php b/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php index 4d6fd6e62c15..cb53a7dd5ed7 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php @@ -186,8 +186,10 @@ public static function _saveUsages(): void $lng = $DIC->language(); if (self::_checkTranslate() and !self::_isPageTranslation()) { - ilSession::set("lang_ext_maintenance", array("used_modules" => array_keys($lng->getUsedModules()))); - ilSession::set("lang_ext_maintenance", array("used_topics" => array_keys($lng->getUsedTopics()))); + ilSession::set("lang_ext_maintenance", array( + "used_modules" => array_keys($lng->getUsedModules()), + "used_topics" => array_keys($lng->getUsedTopics()) + )); } } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php b/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php deleted file mode 100755 index f3a53f90daab..000000000000 --- a/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php +++ /dev/null @@ -1,185 +0,0 @@ -ilDB = $ilDB; - $this->key = $key; - $this->content = $content; - $this->local_changes = $local_changes; - $this->scope = $scope; - if ($scope === "local") { - $this->change_date = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) - ->format('Y-m-d H:i:s'); - } - $this->separator = $separator; - $this->comment_separator = $comment_separator; - } - - public function insertLangEntries(string $lang_file): array - { - // initialize the array for updating lng_modules below - $lang_array = array(); - $lang_array["common"] = array(); - - $double_checker = []; - $query_check = false; - $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES "; - foreach ($this->content as $val) { - // split the line of the language file - // [0]: module - // [1]: identifier - // [2]: value - // [3]: comment (optional) - $separated = explode($this->separator, trim($val)); - $pos = strpos($separated[2], $this->comment_separator); - if ($pos !== false) { - $separated[3] = substr($separated[2], $pos + strlen($this->comment_separator)); - $separated[2] = substr($separated[2], 0, $pos); - } - - // check if the value has a local change - $local_value = $this->local_changes[$separated[0]][$separated[1]] ?? ""; - - if (empty($this->scope)) { - // import of a global language file - if ($local_value !== "" && $local_value !== $separated[2]) { - // keep an existing and different local value - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } elseif ($this->scope === "local") { - // import of a local language file - if ($local_value !== "") { - // keep a locally changed value that is newer than the file - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } - if ($double_checker[$separated[0]][$separated[1]][$this->key] ?? false) { - global $DIC; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Duplicate Language Entry in $lang_file:\n$val", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - $double_checker[$separated[0]][$separated[1]][$this->key] = true; - - $query .= sprintf( - "(%s,%s,%s,%s,%s,%s),", - $this->ilDB->quote($separated[0], "text"), - $this->ilDB->quote($separated[1], "text"), - $this->ilDB->quote($this->key, "text"), - $this->ilDB->quote($separated[2], "text"), - $this->ilDB->quote($this->change_date, "timestamp"), - $this->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),local_change=VALUES(local_change),remarks=VALUES(remarks);"; - if ($query_check) { - $this->ilDB->manipulate($query); - } - - return $lang_array; - } - - public function replaceLangModules(array $lang_array): void - { - // avoid flushing the whole cache (see mantis #28818) - ilCachedLanguage::getInstance($this->key)->deleteInCache(); - - $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES "; - $modules_to_delete = []; - foreach ($lang_array as $module => $lang_arr) { - if ($this->scope === "local") { - $q = "SELECT * FROM lng_modules WHERE " . - " lang_key = " . $this->ilDB->quote($this->key, "text") . - " AND module = " . $this->ilDB->quote($module, "text"); - $set = $this->ilDB->query($q); - $row = $this->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),", - $this->ilDB->quote($module, "text"), - $this->ilDB->quote($this->key, "text"), - $this->ilDB->quote(serialize($lang_arr), "clob"), - ); - $modules_to_delete[] = $module; - } - - $inModulesToDelete = $this->ilDB->in('module', $modules_to_delete, false, 'text'); - $this->ilDB->manipulate(sprintf( - "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete", - $this->ilDB->quote($this->key, "text") - )); - - $query = rtrim($query, ",") . ";"; - $this->ilDB->manipulate($query); - - // check if the module is correctly saved - // see mantis #20046 and #19140 - $this->checkModules(); - } - - protected function checkModules(): void - { - $result = $this->ilDB->queryF( - "SELECT module, lang_array FROM lng_modules WHERE lang_key = %s", - array("text"), - array($this->key) - ); - - foreach ($this->ilDB->fetchAll($result) as $module) { - $unserialied = unserialize($module["lang_array"], ["allowed_classes" => false]); - if (!is_array($unserialied)) { - global $DIC; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Data for module '" . $module["module"] . "' of language '" . $this->key . "' is not correctly saved. " . - "Please check the collation of your database tables lng_data and lng_modules. It must be utf8_unicode_ci.", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - } - } -} diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index 162b832986a7..a9568c1f0ec8 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -362,7 +362,7 @@ public static function _getValues( $ilDB = $DIC->database(); $lng = $DIC->language(); - $q = "SELECT * FROM lng_data WHERE" . + $q = "SELECT module, identifier, value FROM lng_data WHERE" . " lang_key = " . $ilDB->quote($a_lang_key, "text") . " "; if (is_array($a_modules) && count($a_modules) > 0) { @@ -449,7 +449,7 @@ public static function _saveValues(string $a_lang_key, array $a_values = array() // save the serialized module entries in lng_modules foreach ($save_array as $module => $entries) { $set = $ilDB->query(sprintf( - "SELECT * FROM lng_modules " . + "SELECT lang_array FROM lng_modules " . "WHERE lang_key = %s AND module = %s", $ilDB->quote($a_lang_key, "text"), $ilDB->quote($module, "text") @@ -522,7 +522,7 @@ public static function _deleteValues(string $a_lang_key, array $a_values = array // save the serialized module entries in lng_modules foreach ($delete_array as $module => $entries) { $set = $ilDB->query(sprintf( - "SELECT * FROM lng_modules " . + "SELECT lang_array FROM lng_modules " . "WHERE lang_key = %s AND module = %s", $ilDB->quote($a_lang_key, "text"), $ilDB->quote($module, "text") diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php index dc5e5bac27dd..05e8c0c1e0cb 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -22,6 +22,9 @@ use ILIAS\FileUpload\Location; use ILIAS\HTTP\Services as HTTPServices; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Language\Activities\AddLanguageEntry; +use ILIAS\Language\Activities\SetLanguageTranslationEnabled; +use ILIAS\Language\RendersActivityErrors; /** * Class ilObjLanguageExtGUI @@ -39,8 +42,12 @@ */ class ilObjLanguageExtGUI extends ilObjectGUI { - private const ILIAS_LANGUAGE_MODULE = "components/ILIAS/Language"; + use RendersActivityErrors; + + private const string ILIAS_LANGUAGE_MODULE = "components/ILIAS/Language"; private string $langmode; + private readonly AddLanguageEntry $add_language_entry; + private readonly SetLanguageTranslationEnabled $set_language_translation_enabled; /** * Constructor @@ -61,6 +68,10 @@ public function __construct($a_data, int $a_id = 0, bool $a_call_by_reference = $lng = $DIC->language(); $this->http = $DIC['http']; $this->refinery = $DIC['refinery']; + $this->add_language_entry = $DIC[AddLanguageEntry::class]; + $this->set_language_translation_enabled = $DIC[SetLanguageTranslationEnabled::class]; + // used by activityErrorMessage() (see RendersActivityErrors) + $this->activity_error_logger = $DIC->logger()->lang(); // language maintenance strings are defined in administration $lng->loadLanguageModule("administration"); @@ -581,8 +592,10 @@ public function downloadObject(): void $tmp["export"]["scope"] = ilUtil::stripSlashes($post_scope); ilSession::set("lang_ext_maintenance", $tmp); + $pos = strpos(ILIAS_VERSION, " "); + $pos = $pos === false ? null : $pos; $filename = "ilias_" . $this->object->key . '_' - . str_replace(".", "_", substr(ILIAS_VERSION, 0, strpos(ILIAS_VERSION, " "))) + . str_replace(".", "_", substr(ILIAS_VERSION, 0, $pos)) . "-" . gmdate("Y-m-d") . ".lang." . $this->getSession()["export"]["scope"]; @@ -761,24 +774,39 @@ public function settingsObject(): void } /** - * Set the language settings + * Set the language settings - see SetLanguageTranslationEnabled. + * + * An unchecked checkbox is not submitted, so a missing/empty "translation" means "disabled". */ public function saveSettingsObject(): void { - global $DIC; - $ilSetting = $DIC->settings(); - - $translate_key = "lang_translate_" . $this->object->key; - - $post_translation = $this->http->request()->getParsedBody()['translation'] ?? ""; - // save and get the page translation setting - $translate = $ilSetting->get($translate_key, '0'); - if (!is_null($post_translation) && $post_translation != $translate) { - $ilSetting->set($translate_key, $post_translation); + $ilUser = $this->user; + + $post_translation = $this->http->request()->getParsedBody()['translation'] ?? null; + $enabled = $post_translation !== null && $post_translation !== ''; + + $result = $this->set_language_translation_enabled->maybePerformAs( + $this->ui_factory->input(), + $ilUser->getId(), + [ + 'language_key' => $this->object->key, + 'enabled' => $enabled, + ] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + $this->tpl->setOnScreenMessage('failure', $error_message); + } elseif ($result->value()['changed']) { + // only shown if the value changed $this->tpl->setOnScreenMessage('success', $this->lng->txt("settings_saved")); } + $form = $this->initNewSettingsForm(); + // rendered directly with a non-persisted message, no redirect (unlike installObject() & Co.) $this->tpl->setContent($form->getHTML()); } @@ -1015,45 +1043,51 @@ protected function initAddNewEntryForm(?string $a_id = null): ilPropertyFormGUI public function saveNewEntryObject(): void { - global $DIC; - $ilDB = $DIC->database(); - $ilCtrl = $DIC->ctrl(); - $ilUser = $DIC->user(); + $ilCtrl = $this->ctrl; + $ilUser = $this->user; $form = $this->initAddNewEntryForm(); if ($form->checkInput()) { $mod = $form->getInput("mod"); $id = $form->getInput("id"); - $lang = array(); + // a language left blank is skipped by AddLanguageEntry + $translations = []; foreach ($this->lng->getInstalledLanguages() as $lang_key) { - $trans = trim($form->getInput("trans_" . $lang_key)); - if ($trans) { - // add single entry - ilObjLanguage::replaceLangEntry( - $mod, - $id, - $lang_key, - $trans, - gmdate("Y-m-d H:i:s"), - $ilUser->getLogin() - ); + $translations[$lang_key] = trim((string) $form->getInput("trans_" . $lang_key)); + } - // add to serialized module - $set = $ilDB->query("SELECT lang_array FROM lng_modules" . - " WHERE lang_key = " . $ilDB->quote($lang_key, "text") . - " AND module = " . $ilDB->quote($mod, "text")); - $row = $ilDB->fetchAssoc($set); - $entries = unserialize($row["lang_array"], ["allowed_classes" => false]); - if (is_array($entries)) { - $entries[$id] = $trans; - ilObjLanguage::replaceLangModule($lang_key, $mod, $entries); - } + $result = $this->add_language_entry->maybePerformAs( + $this->ui_factory->input(), + $ilUser->getId(), + [ + 'module' => $mod, + 'identifier' => $id, + 'translations' => $translations, + ] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + if ($this->isActivityInputError($error)) { + // input rejection (e.g. blank "de"/"en"): re-render the form, keeping the entered values + $this->tpl->setOnScreenMessage('failure', $error_message); + $form->setValuesByPost(); + $this->addNewEntryObject($form); + return; } + + // unexpected failure (logged by activityErrorMessage()): redirect, like every other Activity error path + $this->tpl->setOnScreenMessage('failure', $error_message, true); + $ilCtrl->redirect($this, "view"); + return; } $this->tpl->setOnScreenMessage('success', $this->lng->txt("settings_saved"), true); $ilCtrl->redirect($this, "view"); + return; } $form->setValuesByPost(); diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index 03a635cc0d19..31b831b9776a 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -20,6 +20,12 @@ use ILIAS\UI\URLBuilder; use ILIAS\UI\URLBuilderToken; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Activities\UninstallLanguage; +use ILIAS\Language\Activities\RemoveLocalLanguageChanges; +use ILIAS\Language\Activities\SetLanguageDetectionEnabled; +use ILIAS\Language\RendersActivityErrors; /** * Class ilObjLanguageFolderGUI @@ -31,26 +37,50 @@ * * @extends ilObject */ -class ilObjLanguageFolderGUI extends ilObjectGUI +class ilObjLanguageFolderGUI extends ilObjectGUI implements ilCtrlSecurityInterface { + use RendersActivityErrors; + + private const string CMD_TABLE_ACTION = 'executeTableAction'; + protected ilLanguageFolderTable $languageFolderTable; protected ILIAS\Data\Factory $df; protected URLBuilder $url_builder; protected URLBuilderToken $action_token; protected URLBuilderToken $id_token; + private readonly InstallLanguage $install_language; + private readonly UpdateLanguage $update_language; + private readonly UninstallLanguage $uninstall_language; + private readonly RemoveLocalLanguageChanges $remove_local_language_changes; + private readonly SetLanguageDetectionEnabled $set_language_detection_enabled; + private readonly int $current_user_id; /** * Constructor */ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference) { + global $DIC; + $this->type = "lngf"; parent::__construct($a_data, $a_id, $a_call_by_reference, false); $this->lng->loadLanguageModule("lng"); + + $this->install_language = $DIC[InstallLanguage::class]; + $this->update_language = $DIC[UpdateLanguage::class]; + $this->uninstall_language = $DIC[UninstallLanguage::class]; + $this->remove_local_language_changes = $DIC[RemoveLocalLanguageChanges::class]; + $this->set_language_detection_enabled = $DIC[SetLanguageDetectionEnabled::class]; + $this->current_user_id = $DIC->user()->getId(); + // used by activityErrorMessage() (see RendersActivityErrors) + $this->activity_error_logger = $DIC->logger()->lang(); $this->df = new ILIAS\Data\Factory(); - $here_uri = $this->df->uri($this->request->getUri()->__toString()); - $url_builder = new URLBuilder($here_uri); + // via ilCtrl, so that the rtoken of the unsafe table action command is appended + $url_builder = new URLBuilder($this->df->uri( + ilUtil::_getHttpPath() . '/' + . $this->ctrl->getLinkTargetByClass([ilAdministrationGUI::class, self::class], self::CMD_TABLE_ACTION) + )); $query_params_namespace = ['language_folder']; [$url_builder, $action_parameter_token, $row_id_token] = $url_builder->acquireParameters( @@ -68,6 +98,20 @@ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference $this->languageFolderTable = new ilLanguageFolderTable($folder, $url_builder, $action_parameter_token, $row_id_token); } + public function getUnsafeGetCommands(): array + { + return [ + self::CMD_TABLE_ACTION, + 'enableLanguageDetection', + 'disableLanguageDetection', + ]; + } + + public function getSafePostCommands(): array + { + return []; + } + /** * show installed languages */ @@ -147,6 +191,11 @@ protected function buildConfirmModal(array $ids, string $title, string $action, $items = []; if (!empty($ids)) { + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + // already aborted (failure message and redirect); the Modal is only needed for the return type + return $f->modal()->interruptive($title, '', '')->withActionButtonLabel($this->lng->txt('ok')); + } + $message = $this->lng->txt($text); $some_changed = false; @@ -189,212 +238,360 @@ protected function buildConfirmModal(array $ids, string $title, string $action, } /** - * install languages + * Localized, comma-separated list of language names. + * + * @param list $lang_keys */ - public function installObject(array $ids): void + private function languageKeysToLocalizedList(array $lang_keys): string { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); + return implode(', ', array_map( + fn(string $key): string => $this->lng->txt('meta_l_' . $key), + $lang_keys + )); + } + /** + * $ids is unvalidated request input, and ilObject::_lookupTitle() returns the title of ANY object type, so every + * id must be confirmed to be a "lng" object before its title is trusted as a language key. + * + * Aborts the WHOLE request (generic failure message, redirect to "view") if a single id is not one. The message + * deliberately does not name the mismatch, so probing the parameter reveals nothing about existing ids. + * + * @param list $ids + * @return bool true if the request was aborted (the caller must return immediately) + */ + private function abortIfAnyIdIsNotALanguageObject(array $ids): bool + { foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install(); - - if ($key !== "") { - $lang_installed[] = $key; - } - - unset($langObj); - } - - if (isset($lang_installed)) { - if (count($lang_installed) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_installed[0]) . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($lang_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data = implode(", ", $langnames) . " " . strtolower($this->lng->txt("installed")) . "."; + if (ilObject::_lookupType((int) $obj_id) !== 'lng') { + $this->tpl->setOnScreenMessage( + 'failure', + $this->lng->txt('obj_not_found') . '
' . $this->lng->txt('action_aborted'), + true + ); + $this->ctrl->redirect($this, 'view'); + return true; } - } else { - $this->data = $this->lng->txt("languages_already_installed"); } - - $this->out(); + return false; } - /** - * Install local language modifications. + * $mode is the dispatched "install"/"install_local" command, both are InstallLanguage modes (MODE_INSTALL/MODE_INSTALL_LOCAL). */ - public function installLocalObject(array $ids): void + public function installObject(array $ids, string $mode): void { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); + $this->checkPermission('write'); + + // an empty selection must not reach the Activity, its validation message is technical and untranslated + if ($ids === []) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_checkbox'), true); + $this->ctrl->redirect($this, 'view'); + return; + } + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + + $language_keys = []; foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install(); + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } - if ($key !== "") { - $lang_installed[] = $key; - } + $result = $this->install_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + [ + 'language_keys' => $language_keys, + 'mode' => $mode, + ] + ); - unset($langObj); + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install("local"); + // activityErrorMessage() is complete: no prefix ("language_not_installed", "action_aborted") applies to every error + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); - if ($key !== "") { - $local_installed[] = $key; - } + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // setOnScreenMessage() keeps one message per type, so messages of one type are combined + $success_messages = []; - unset($langObj); + if (($lang_installed = $value['installed_language_keys']) !== []) { + $success_messages[] = $this->languageKeysToLocalizedList($lang_installed) + . " " . strtolower($this->lng->txt("installed")) . "."; } - if (isset($lang_installed)) { - if (count($lang_installed) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_installed[0]) . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($lang_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data = implode(", ", $langnames) . " " . strtolower($this->lng->txt("installed")) . "."; - } + // reported explicitly, else it would vanish into "already installed" and suggest nothing happened + if (($lang_installed_with_local_file = $value['installed_with_local_language_keys']) !== []) { + $success_messages[] = $this->languageKeysToLocalizedList($lang_installed_with_local_file) + . ": " . $this->lng->txt("installed_local") . "."; } - if (isset($local_installed)) { - if (count($local_installed) === 1) { - $this->data .= " " . $this->lng->txt("meta_l_" . $local_installed[0]) . " " . $this->lng->txt("local_language_file") . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($local_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data .= " " . implode(", ", $langnames) . " " . $this->lng->txt("local_language_files") . " " . strtolower($this->lng->txt("installed")) . "."; - } - } else { - $this->data .= " " . $this->lng->txt("local_languages_already_installed"); + if ($success_messages !== []) { + $this->tpl->setOnScreenMessage( + 'success', + implode('
', $success_messages), + true + ); } - $this->out(); + $info_messages = []; + + if (($lang_already_installed = $value['already_installed_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("languages_already_installed") . ': ' + . $this->languageKeysToLocalizedList($lang_already_installed); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $info_messages[] = $this->languageKeysToLocalizedList($lang_not_installed) + . " " . $this->lng->txt("language_not_installed"); + } + + if ($info_messages !== []) { + $this->tpl->setOnScreenMessage( + 'info', + implode('
', $info_messages), + true + ); + } + + if (($invalid_local_language_files = $value['invalid_local_language_files']) !== []) { + $message = $this->lng->txt('local_language_files') . ': ' + . implode(', ', $invalid_local_language_files) . '. ' + . $this->lng->txt('file_not_valid'); + $this->tpl->setOnScreenMessage( + 'failure', + $message, + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** - * uninstall language + * Languages that are not installed, the system language and the language in use are left untouched. */ public function uninstallObject(array $ids): void { $this->checkPermission('write'); $this->lng->loadLanguageModule("meta"); - $sys_lang = false; - $usr_lang = false; + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } - // uninstall all selected languages + $language_keys = []; foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - if (!($sys_lang = $langObj->isSystemLanguage()) && !($usr_lang = $langObj->isUserLanguage())) { - $key = $langObj->uninstall(); - if ($key !== "") { - $lang_uninstalled[] = $key; - } - } - unset($langObj); + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); } - // generate output message - if (isset($lang_uninstalled)) { - if (count($lang_uninstalled) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_uninstalled[0]) . " " . $this->lng->txt("uninstalled"); - } else { - $langnames = []; - foreach ($lang_uninstalled as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } + $result = $this->uninstall_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); - $this->data = implode(", ", $langnames) . " " . $this->lng->txt("uninstalled"); - } - } elseif ($sys_lang) { - $this->data = $this->lng->txt("cannot_uninstall_systemlanguage"); - } elseif ($usr_lang) { - $this->data = $this->lng->txt("cannot_uninstall_language_in_use"); - } else { - $this->data = $this->lng->txt("languages_already_uninstalled"); + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; } + $value = $result->value(); - $this->out(); + if (($lang_uninstalled = $value['uninstalled_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->languageKeysToLocalizedList($lang_uninstalled) . " " . $this->lng->txt("uninstalled"), + true + ); + } + + $info_messages = []; + + if (($lang_system = $value['system_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("cannot_uninstall_systemlanguage") . ': ' + . $this->languageKeysToLocalizedList($lang_system); + } + + if (($lang_in_use = $value['user_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("cannot_uninstall_language_in_use") . ': ' + . $this->languageKeysToLocalizedList($lang_in_use); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("languages_already_uninstalled") . ': ' + . $this->languageKeysToLocalizedList($lang_not_installed); + } + + if ($info_messages !== []) { + $this->tpl->setOnScreenMessage('info', implode('
', $info_messages), true); + } + + $this->ctrl->redirect($this, 'view'); } /** - * Uninstall local changes in the database + * Languages that are not installed (or no language key) are left untouched. */ public function uninstallChangesObject(array $ids): void { $this->checkPermission("write"); - - $this->data = $this->lng->txt("selected_languages_updated"); $this->lng->loadLanguageModule("meta"); - $refreshed = []; - foreach ($ids as $id) { - $langObj = new ilObjLanguage((int) $id, false); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } - if ($langObj->isInstalled()) { - if ($langObj->check()) { - $langObj->flush("all"); - $langObj->insert(); - $langObj->setTitle($langObj->getKey()); - $langObj->setDescription("installed"); - $langObj->update(); - $refreshed[] = $langObj->getKey(); - } - $this->data .= "
" . $this->lng->txt("meta_l_" . $langObj->getKey()); - } + $language_keys = []; + foreach ($ids as $obj_id) { + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } + + $result = $this->remove_local_language_changes->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); - unset($langObj); + $this->ctrl->redirect($this, 'view'); + return; } - ilObjLanguage::refreshPlugins($refreshed); - $this->out(); - } + $value = $result->value(); - /** - * update all installed languages - */ - public function refreshObject(): void - { - $this->checkPermission("write"); + // Plugin language files are refreshed only for the languages whose + // local changes were actually removed - a requested-but-not-installed + // or invalid language (see below) has nothing to refresh. + ilObjLanguage::refreshPlugins($value['removed_local_changes_language_keys']); - ilObjLanguage::refreshAll(); - $this->data = $this->lng->txt("languages_updated"); - $this->out(); + if (($lang_changed = $value['removed_local_changes_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->lng->txt("selected_languages_updated") . "
" + . $this->languageKeysToLocalizedList($lang_changed), + true + ); + } + + if (($lang_invalid = $value['invalid_language_file_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'failure', + $this->languageKeysToLocalizedList($lang_invalid) . ": " . $this->lng->txt("file_not_valid"), + true + ); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'info', + $this->languageKeysToLocalizedList($lang_not_installed) + . " " . $this->lng->txt("language_not_installed"), + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** - * update selected languages + * Languages that are not installed are left untouched. */ public function refreshSelectedObject(array $ids): void { - $this->checkPermission("write"); - $this->data = $this->lng->txt("selected_languages_updated"); - $this->lng->loadLanguageModule("meta"); + $this->checkPermission('write'); - $refreshed = []; - foreach ($ids as $id) { - $langObj = new ilObjLanguage((int) $id, false); - if ($langObj->refresh()) { - $refreshed[] = $langObj->getKey(); - $this->data .= "
" . $this->lng->txt("meta_l_" . $langObj->getKey()); - } - unset($langObj); + // see installObject() + if ($ids === []) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_checkbox'), true); + $this->ctrl->redirect($this, 'view'); + return; } - ilObjLanguage::refreshPlugins($refreshed); - $this->out(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + + $language_keys = []; + foreach ($ids as $obj_id) { + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } + + $result = $this->update_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // Plugin language files are refreshed only for the languages that + // were actually updated - a requested-but-not-installed language + // (see not_installed_language_keys below) has nothing to refresh. + ilObjLanguage::refreshPlugins($value['updated_language_keys']); + + if (($lang_updated = $value['updated_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->lng->txt('selected_languages_updated') . ' ' + . $this->languageKeysToLocalizedList($lang_updated), + true + ); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'info', + $this->languageKeysToLocalizedList($lang_not_installed) + . ' ' . $this->lng->txt('language_not_installed'), + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** @@ -408,9 +605,6 @@ public function setUserLanguageObject(array $ids): void $this->checkPermission("write"); $this->lng->loadLanguageModule("meta"); - #require_once "./Services/User/classes/class.ilObjUser.php"; - - if (count($ids) !== 1) { $this->tpl->setOnScreenMessage('failure', $this->lng->txt("choose_only_one_language") . "
" . $this->lng->txt("action_aborted"), true); $this->ctrl->redirect($this, "view"); @@ -533,139 +727,129 @@ public function executeCommand(): void default: $this->tabs_gui->activateTab("settings"); - if ($action = $this->getCommandFromQueryToken($this->action_token->getName())) { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); - $f = $this->ui_factory; - $r = $this->ui_renderer; - switch ($action) { - case 'confirmRefresh': - $ids = $this->confirmRefreshObject(); - $this->refreshSelectedObject($ids); - break; - case 'checkLanguage': - $this->checkLanguageObject(); - break; - case 'refresh': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'refreshConfirmed', - 'lang_refresh_confirm_selected', - "lang_refresh_confirm_info" - ); - echo($r->renderAsync([$modal])); - exit(); - case 'refreshConfirmed': - $ids = $this->getIdsFromQueryToken(); - $this->refreshSelectedObject($ids); - break; - case 'uninstall': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'uninstallConfirmed', - 'lang_uninstall_confirm' - ); - echo($r->renderAsync([$modal])); - exit(); - case 'uninstallConfirmed': - $ids = $this->getIdsFromQueryToken(); - $this->uninstallObject($ids); - break; - case 'install': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if (!$langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $this->installObject($ids); - break; - case 'install_local': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $ids[] = (string) $lang["obj_id"]; - } - } - $this->installLocalObject($ids); - break; - case 'lang_uninstall_changes': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'uninstallChanges', - 'lang_uninstall_changes_confirm' - ); - echo($r->renderAsync([$modal])); - exit(); - case 'uninstallChanges': - $ids = $this->getIdsFromQueryToken(); - $this->uninstallChangesObject($ids); - break; - case 'setSystemLanguage': - $ids = $this->getIdsFromQueryToken(); - $this->setSystemLanguageObject($ids); - break; - case 'setUserLanguage': - $ids = $this->getIdsFromQueryToken(); - $this->setUserLanguageObject($ids); - break; - case 'edit': - $ids = $this->getIdsFromQueryToken(); - $this->editFolderObject($ids); - break; + // exact match: ilCtrl compares the unsafe commands case-sensitively, PHP method calls are not + match ($cmd) { + self::CMD_TABLE_ACTION, + 'enableLanguageDetection', + 'disableLanguageDetection', + 'listDeprecated', + 'downloadDeprecated' => $this->{$cmd . 'Object'}(), + default => $this->viewObject(), + }; + + break; + } + } + + protected function executeTableActionObject(): void + { + $this->checkPermission("write"); + $this->lng->loadLanguageModule("meta"); + $r = $this->ui_renderer; + switch ($action = $this->getCommandFromQueryToken($this->action_token->getName())) { + case 'confirmRefresh': + $ids = $this->confirmRefreshObject(); + $this->refreshSelectedObject($ids); + break; + case 'checkLanguage': + $this->checkLanguageObject(); + break; + case 'refresh': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } } } - if (!$cmd) { - $cmd = "view"; + $modal = $this->buildConfirmModal( + $ids, + $action, + 'refreshConfirmed', + 'lang_refresh_confirm_selected', + "lang_refresh_confirm_info" + ); + echo($r->renderAsync([$modal])); + exit(); + case 'refreshConfirmed': + $ids = $this->getIdsFromQueryToken(); + $this->refreshSelectedObject($ids); + break; + case 'uninstall': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } + } } - $cmd .= "Object"; - $this->$cmd(); - + $modal = $this->buildConfirmModal( + $ids, + $action, + 'uninstallConfirmed', + 'lang_uninstall_confirm' + ); + echo($r->renderAsync([$modal])); + exit(); + case 'uninstallConfirmed': + $ids = $this->getIdsFromQueryToken(); + $this->uninstallObject($ids); + break; + case 'install': + case 'install_local': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $ids[] = (string) $lang["obj_id"]; + } + } + // 'install'/'install_local' are the InstallLanguage mode values + $this->installObject($ids, $action); + break; + case 'lang_uninstall_changes': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } + } + } + $modal = $this->buildConfirmModal( + $ids, + $action, + 'uninstallChanges', + 'lang_uninstall_changes_confirm' + ); + echo($r->renderAsync([$modal])); + exit(); + case 'uninstallChanges': + $ids = $this->getIdsFromQueryToken(); + $this->uninstallChangesObject($ids); + break; + case 'setSystemLanguage': + $ids = $this->getIdsFromQueryToken(); + $this->setSystemLanguageObject($ids); + break; + case 'setUserLanguage': + $ids = $this->getIdsFromQueryToken(); + $this->setUserLanguageObject($ids); break; } + $this->viewObject(); } protected function getCommandFromQueryToken(string $param): ?string @@ -696,154 +880,39 @@ public function confirmRefreshObject(): array return $ids; } - public function confirmRefreshSelectedObject(array $a_ids = []): void + protected function disableLanguageDetectionObject(): void { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); - - $header = ''; - $ids = []; - if (!empty($a_ids)) { - $ids = $a_ids; - $header = $this->lng->txt("lang_refresh_confirm"); - } elseif (!empty($post_id = $this->getIdsFromQueryToken())) { - $ids = $post_id; - $header = $this->lng->txt("lang_refresh_confirm_selected"); - } else { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt("no_checkbox"), true); - $this->ctrl->redirect($this, "view"); - } - - $conf_screen = new ilConfirmationGUI(); - $some_changed = false; - foreach ($ids as $id) { - $lang_key = ilObject::_lookupTitle((int) $id); - $lang_title = $this->lng->txt("meta_l_" . $lang_key); - $last_change = ilObjLanguage::_getLastLocalChange($lang_key); - if (!empty($last_change)) { - $some_changed = true; - $lang_title .= " (" . $this->lng->txt("last_change") . " " - . ilDatePresentation::formatDate(new ilDateTime($last_change, IL_CAL_DATETIME)) . ")"; - } - $conf_screen->addItem("id[]", (string) $id, $lang_title); - } - - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - if ($some_changed) { - $header .= "
" . $this->lng->txt("lang_refresh_confirm_info"); - } - $conf_screen->setHeaderText($header); - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "refreshSelected"); - $this->tpl->setContent($conf_screen->getHTML()); + $this->setLanguageDetectionEnabledObject(false); } - public function confirmUninstallObject(): void + protected function enableLanguageDetectionObject(): void { - $this->checkPermission("write"); - - $this->lng->loadLanguageModule("meta"); - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_confirm")); - foreach ($this->getIdsFromQueryToken() as $id) { - $lang_title = ilObject::_lookupTitle((int) $id); - $conf_screen->addItem("id[]", $id, $this->lng->txt("meta_l_" . $lang_title)); - } - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "uninstall"); - $this->tpl->setContent($conf_screen->getHTML()); + $this->setLanguageDetectionEnabledObject(true); } - public function confirmUninstallChangesObject(): void + private function setLanguageDetectionEnabledObject(bool $enabled): void { - $this->checkPermission('write'); - - $this->lng->loadLanguageModule("meta"); - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_changes_confirm")); - foreach ($this->getIdsFromQueryToken() as $id) { - $lang_title = ilObject::_lookupTitle($id); - $conf_screen->addItem("id[]", (string) $id, $this->lng->txt("meta_l_" . $lang_title)); - } - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "uninstallChanges"); - $this->tpl->setContent($conf_screen->getHTML()); - } + $result = $this->set_language_detection_enabled->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['enabled' => $enabled] + ); - /** - * Get Actions - */ - public function getActions(): array - { - $f = $this->ui_factory; - return [ - 'confirmRefreshSelected' => $f->table()->action()->standard( - $this->lng->txt("refresh"), - $this->url_builder->withParameter($this->action_token, "confirmRefreshSelected"), - $this->id_token - )->withAsync(), - 'install' => $f->table()->action()->standard( - $this->lng->txt("install"), - $this->url_builder->withParameter($this->action_token, "install"), - $this->id_token - ), - 'installLocal' => $f->table()->action()->standard( - $this->lng->txt("install_local"), - $this->url_builder->withParameter($this->action_token, "installLocal"), - $this->id_token - ), - 'confirmUninstall' => $f->table()->action()->standard( - $this->lng->txt("uninstall"), - $this->url_builder->withParameter($this->action_token, "confirmUninstall"), - $this->id_token - ), - 'confirmUninstallChanges' => $f->table()->action()->standard( - $this->lng->txt("lang_uninstall_changes"), - $this->url_builder->withParameter($this->action_token, "confirmUninstallChanges"), - $this->id_token - ), - 'setSystemLanguage' => $f->table()->action()->single( - $this->lng->txt("setSystemLanguage"), - $this->url_builder->withParameter($this->action_token, "setSystemLanguage"), - $this->id_token - ), - 'setUserLanguage' => $f->table()->action()->single( - $this->lng->txt("setUserLanguage"), - $this->url_builder->withParameter($this->action_token, "setUserLanguage"), - $this->id_token - ), - 'editFolder' => $f->table()->action()->single( - $this->lng->txt("edit"), - $this->url_builder->withParameter($this->action_token, "editFolder"), - $this->id_token - ), - ]; - } + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); - protected function editFolderObject(array $ids): void - { - $this->ctrl->setParameterByClass("ilobjlanguageextgui", "obj_id", current($ids)); - $this->ctrl->redirectByClass("ilobjlanguageextgui"); - } + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); - /** - * Disable language detection - */ - protected function disableLanguageDetectionObject(): void - { - $this->settings->set("lang_detection", '0'); - $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); - $this->viewObject(); - } + $this->ctrl->redirect($this, 'view'); + return; + } - /** - * Enable language detection - */ - protected function enableLanguageDetectionObject(): void - { - $this->settings->set("lang_detection", '1'); + // rendered directly with a non-persisted message, no redirect (unlike installObject() & Co.) $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); $this->viewObject(); } @@ -859,8 +928,6 @@ public function listDeprecatedObject(): void ); $this->toolbar->addComponent($button); - include_once "./Services/Language/classes/class.ilLangDeprecated.php"; - $d = new ilLangDeprecated(); $res = ""; foreach ($d->getDeprecatedLangVars() as $key => $mod) { @@ -875,7 +942,6 @@ public function listDeprecatedObject(): void */ public function downloadDeprecatedObject(): void { - include_once "./Services/Language/classes/class.ilLangDeprecated.php"; $d = new ilLangDeprecated(); $res = ""; foreach ($d->getDeprecatedLangVars() as $key => $mod) { diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php new file mode 100644 index 000000000000..d578807fa0ec --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -0,0 +1,229 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Adds one new "adjust language variables" entry (a module/identifier pair) to +every currently installed language for which a value was given. An installed +language for which no value (or only a blank one) was given is left +completely untouched. + +"de" and "en" are mandatory whenever they are installed: a missing or blank +value for either rejects the request as a whole - nothing is written for any +language, not even ones with a perfectly valid value given. +MARKDOWN + ); + } + + /** + * Signature must stay exactly the Activity's (no extra parameter), so the installed languages + * are resolved here and may differ from perform()'s snapshot; perform() then fails closed. + */ + public function getInputDescription(FieldFactory $f): FormInput + { + $installed_language_keys = $this->repository->getInstalledLanguages(); + + // labels need the "meta" module loaded by the caller; loading it here would clobber keys in the shared $lng + $module = $f->text( + 'Module', + 'Name of the language module the new entry belongs to.' + )->withRequired(true)->withDedicatedName('module'); + + $identifier = $f->text( + 'Identifier', + 'Identifier (topic) of the new language entry.' + )->withRequired(true)->withDedicatedName('identifier'); + + $translation_fields = []; + foreach ($installed_language_keys as $lang_key) { + $is_mandatory = in_array($lang_key, ['de', 'en'], true); + $translation_fields[$lang_key] = $f->text( + $this->lng->txt('meta_l_' . $lang_key) + )->withRequired($is_mandatory)->withDedicatedName($lang_key); + } + + $translations = $f->group( + $translation_fields, + 'Translations', + 'Value of the new entry for each installed language; "de" and "en" are mandatory ' . + '(if installed) - a missing/blank value for either rejects the whole request, ' . + 'nothing is written for any language. Every other installed language is optional ' . + 'and simply skipped if left blank.' + )->withDedicatedName('translations'); + + return $f->section([ + 'module' => $module, + 'identifier' => $identifier, + 'translations' => $translations, + ], 'Add a language entry'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of adding the language entry.'), + [ + 'module' => $f->string($text->simpleDocument('Language module the new entry was added to.')), + 'identifier' => $f->string($text->simpleDocument('Identifier of the new language entry.')), + 'added_language_keys' => $f->list( + $text->simpleDocument( + 'Installed languages for which a non-blank value was given - the entry was ' . + 'written for them.' + ), + $f->string($text->simpleDocument('Language key of a language the entry was added for.')) + ), + 'skipped_empty_language_keys' => $f->list( + $text->simpleDocument( + 'Installed languages for which no value (or only a blank one) was given - ' . + 'left completely untouched, since there is nothing to write for them.' + ), + $f->string($text->simpleDocument('Language key of a language with no given value.')) + ), + ] + ); + } + + /** + * @param mixed $parameters may carry a trusted `usr_id` (int), set by maybePerformAs(); + * it is recorded as the author of the local change, without it no author is attributed. + */ + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $module = $parameters['module'] ?? null; + $identifier = $parameters['identifier'] ?? null; + $translations = $parameters['translations'] ?? null; + $usr_id = $parameters['usr_id'] ?? null; + + if (!is_string($module) || $module === '' + || !is_string($identifier) || $identifier === '' + || !is_array($translations) + || ($usr_id !== null && !is_int($usr_id)) + ) { + throw new InvalidInputException( + 'The module, identifier and translations parameters are required; usr_id, if given, must be an int.' + ); + } + + $installed_language_keys = $this->repository->getInstalledLanguages(); + + $missing_mandatory_language_keys = []; + foreach (['de', 'en'] as $mandatory_lang_key) { + if (!in_array($mandatory_lang_key, $installed_language_keys, true)) { + continue; + } + + $value = $translations[$mandatory_lang_key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + + if (!$value) { + $missing_mandatory_language_keys[] = $mandatory_lang_key; + } + } + + if ($missing_mandatory_language_keys !== []) { + throw new InvalidInputException( + 'A value is required for: ' . implode(', ', $missing_mandatory_language_keys) . '.' + ); + } + + // '' (no usr_id) means "no remarks" for replaceLangEntry() + $login = is_int($usr_id) ? $this->repository->lookupLogin($usr_id) : ''; + $local_change = gmdate('Y-m-d H:i:s'); + + $added_language_keys = []; + $skipped_empty_language_keys = []; + + // not transactional: a failure partway leaves the languages processed so far written + foreach ($installed_language_keys as $lang_key) { + $value = $translations[$lang_key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + + // "0" counts as blank too (legacy behaviour) + if (!$value) { + $skipped_empty_language_keys[] = $lang_key; + continue; + } + + $this->repository->replaceLangEntry($module, $identifier, $lang_key, $value, $local_change, $login); + $this->repository->updateModuleCache($lang_key, $module, $identifier, $value); + + $added_language_keys[] = $lang_key; + } + + return [ + 'module' => $module, + 'identifier' => $identifier, + 'added_language_keys' => $added_language_keys, + 'skipped_empty_language_keys' => $skipped_empty_language_keys, + ]; + } + + /** + * @param array{module: string, identifier: string, translations: array} $grind_result + * @return array{module: string, identifier: string, translations: array} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'module' => $this->toNonEmptyString($grind_result['module'], 'module'), + 'identifier' => $this->toNonEmptyString($grind_result['identifier'], 'identifier'), + 'translations' => $grind_result['translations'], + ]; + } + + private function toNonEmptyString(mixed $value, string $field): string + { + if (!is_string($value) || trim($value) === '') { + // withRequired(true) lets whitespace-only strings pass + throw new InvalidInputException("The $field parameter must be a non-empty string."); + } + + return trim($value); + } +} diff --git a/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php b/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php new file mode 100644 index 000000000000..a2fe6693d090 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php @@ -0,0 +1,29 @@ +text( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_keys'); + + return $f->section([ + 'language_keys' => $language_keys, + ], $this->inputSectionTitle()); + } + + /** + * @param array{language_keys: string} $grind_result + * @return array{language_keys: list} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_keys' => $this->toLanguageKeyList($grind_result['language_keys']), + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/Failure.php b/components/ILIAS/Language/src/Activities/Failure.php new file mode 100644 index 000000000000..ab8c80a0b893 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/Failure.php @@ -0,0 +1,39 @@ +getCode(); + if ($e instanceof \Exception && is_int($code) && $code >= 400 && $code < 500) { + return $e; + } + + return new \RuntimeException('The activity could not be performed.', 500, $e); + } +} diff --git a/components/ILIAS/Language/src/Activities/GrindingInputAccess.php b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php new file mode 100644 index 000000000000..22faf89b197b --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php @@ -0,0 +1,114 @@ +withNameFrom(new FormInputNameSource()); + } + + /** @param array $flat_raw_values keyed by the HTML input names assigned by named() */ + public static function withRawValues(FormInput $named, array $flat_raw_values): FormInput + { + self::assertInternal($named, 'withInput()'); + + /** @var InputInternal&FormInput $named */ + return $named->withInput(new ArrayInputData($flat_raw_values)); + } + + public static function content(FormInput $with_input): Result + { + self::assertInternal($with_input, 'getContent()'); + + /** @var InputInternal $with_input */ + return $with_input->getContent(); + } + + public static function nameOf(Input $input): ?string + { + return $input instanceof InputInternal ? $input->getName() : null; + } + + public static function errorOf(Input $input): ?string + { + return $input instanceof InputInternal ? $input->getError() : null; + } + + public static function isNameable(Input $input): bool + { + return $input instanceof InputInternal; + } + + public static function requireNameable(Input $input, string $exception_message): void + { + if (!$input instanceof InputInternal) { + throw new \LogicException($exception_message); + } + } + + /** Only available on the concrete implementation base class, not on the public `Input` interface. */ + public static function dedicatedNameOf(Input $input): ?string + { + return $input instanceof \ILIAS\UI\Implementation\Component\Input\Input + ? $input->getDedicatedName() + : null; + } + + private static function assertInternal(FormInput $input, string $needed_for): void + { + if (!$input instanceof InputInternal) { + throw new \LogicException( + 'Expected a FormInput also implementing ' . InputInternal::class . ' (needed for ' . + $needed_for . '), got: ' . get_debug_type($input) + ); + } + } +} diff --git a/components/ILIAS/Language/src/Activities/GrindsFormInput.php b/components/ILIAS/Language/src/Activities/GrindsFormInput.php new file mode 100644 index 000000000000..b7cc00bfe868 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindsFormInput.php @@ -0,0 +1,197 @@ + $raw_parameters + */ + private function grind(FormInput $description, array $raw_parameters): Result + { + try { + $named = GrindingInputAccess::named($description, static::class); + + $flat_input = []; + // unknown keys are tolerated on the outermost group, but rejected in nested ones + $this->collectRawValues($named, $raw_parameters, $flat_input); + + $with_input = GrindingInputAccess::withRawValues($named, $flat_input); + $content = GrindingInputAccess::content($with_input); + + if ($content->isError()) { + return new Result\Error($this->describeInputError($with_input)); + } + + return new Result\Ok($content->value()); + } catch (InvalidInputException $e) { + return new Result\Error($e); + } catch (\InvalidArgumentException $e) { + // a malformed request value, never an internal misconfiguration: safe to show the user + return new Result\Error(new InvalidInputException($e->getMessage(), 400, $e)); + } + } + + /** + * @param array $flat + * @param bool $enforce_known_keys false for the outermost group (unrelated top-level keys are + * ignored); nested group keys come from our own getInputDescription(), so unknown ones fail + */ + private function collectRawValues(Input $named, mixed $raw_value, array &$flat, bool $enforce_known_keys = false): void + { + if ($named instanceof GroupInput) { + // null = group omitted (treated as empty); any other non-array is a type mismatch + if ($raw_value !== null && !is_array($raw_value)) { + throw new InvalidInputException( + 'Expected an array of values (or none at all) for ' + . $this->fieldLabelForErrorMessage($named) . ', got: ' . get_debug_type($raw_value) + ); + } + $sub_raw = $raw_value ?? []; + + if ($enforce_known_keys) { + $unknown_keys = array_diff(array_keys($sub_raw), array_keys($named->getInputs())); + if ($unknown_keys !== []) { + throw new InvalidInputException( + 'Unknown key(s) for ' . $this->fieldLabelForErrorMessage($named) . ': ' + . implode(', ', array_map(static fn(int|string $key): string => (string) $key, $unknown_keys)) + ); + } + } + + foreach ($named->getInputs() as $key => $child) { + $this->collectRawValues($child, $sub_raw[$key] ?? null, $flat, true); + } + return; + } + + GrindingInputAccess::requireNameable( + $named, + 'Every leaf field of a grindable FormInput must implement the UI framework\'s ' . + 'input-processing internals.' + ); + + $name = GrindingInputAccess::nameOf($named); + if ($name === null) { + throw new \LogicException('Every field of a grindable FormInput must have a name.'); + } + + $flat[$name] = match (true) { + $named instanceof Checkbox => $this->normalizeCheckboxRawValue($raw_value), + $named instanceof TextField && is_array($raw_value) => $this->joinListOfStringsRawValue($raw_value), + default => $raw_value ?? '', + }; + } + + /** + * @param array $raw_value + * @return array|string comma-joined if all items are strings, otherwise unchanged + */ + private function joinListOfStringsRawValue(array $raw_value): array|string + { + foreach ($raw_value as $item) { + if (!is_string($item)) { + return $raw_value; + } + } + + return implode(',', $raw_value); + } + + private function normalizeCheckboxRawValue(mixed $value): string + { + if ( + $value === true || $value === 1 || $value === '1' || $value === 'true' + || $value === 'checked' || $value === 'on' + ) { + return 'checked'; + } + if ( + $value === false || $value === 0 || $value === '0' + || $value === '' || $value === 'false' || $value === null + ) { + return ''; + } + + throw new \InvalidArgumentException( + 'Expected a boolean or one of the common primitive representations of true/false ' . + '(true/false, 1/0, "1"/"0", "true"/"false", "checked"/"on", "" or null), got: ' + . get_debug_type($value) + ); + } + + private function describeInputError(FormInput $with_input): InvalidInputException + { + $field_errors = []; + $this->collectFieldErrors($with_input, $field_errors); + + if ($field_errors === []) { + $error = GrindingInputAccess::errorOf($with_input); + $field_errors[] = $error ?? 'Invalid input.'; + } + + return new InvalidInputException(implode('; ', $field_errors)); + } + + /** @param list $field_errors */ + private function collectFieldErrors(Input $named, array &$field_errors): void + { + if ($named instanceof GroupInput) { + foreach ($named->getInputs() as $child) { + $this->collectFieldErrors($child, $field_errors); + } + return; + } + + if (!GrindingInputAccess::isNameable($named)) { + return; + } + + $error = GrindingInputAccess::errorOf($named); + if ($error !== null) { + $field_errors[] = $this->fieldLabelForErrorMessage($named) . ': ' . $error; + } + } + + private function fieldLabelForErrorMessage(Input $named): string + { + $dedicated_name = GrindingInputAccess::dedicatedNameOf($named); + if ($dedicated_name !== null) { + return $dedicated_name; + } + + return GrindingInputAccess::nameOf($named) ?? '?'; + } +} diff --git a/components/ILIAS/Language/src/Activities/InstallLanguage.php b/components/ILIAS/Language/src/Activities/InstallLanguage.php new file mode 100644 index 000000000000..f7ae7d7b44a9 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -0,0 +1,254 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Installs one or more languages in the ILIAS system, or applies just their +customizing/local language file, depending on the chosen mode. + +Mode "install" fully installs a language that is not yet installed (base +data, plus a customizing/local file if one exists) and leaves an already +installed language completely untouched. Mode "install_local" instead +(re-)applies only the customizing/local file on top of an already installed +language, without touching its base data, and leaves a not-yet-installed +language completely untouched. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $language_keys = $f->text( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_keys'); + + $mode = $f->select( + 'Mode', + [ + self::MODE_INSTALL => 'Install', + self::MODE_INSTALL_LOCAL => 'Install local', + ], + 'Whether to fully install the given languages, or to only ' . + '(re-)apply their customizing/local file on top of an existing ' . + 'installation.' + )->withRequired(true)->withDedicatedName('mode'); + + return $f->section([ + 'language_keys' => $language_keys, + 'mode' => $mode, + ], 'Install languages'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of the language installation.'), + [ + 'installed_language_keys' => $f->list( + $text->simpleDocument('Newly installed languages without a custom language file.'), + $f->string($text->simpleDocument('Language key of a newly installed language.')) + ), + 'installed_with_local_language_keys' => $f->list( + $text->simpleDocument( + 'Languages for which a custom/local language file was (re-)installed - ' . + 'either as part of a fresh installation (mode "install"), or applied on ' . + 'top of an already installed language (mode "install_local").' + ), + $f->string($text->simpleDocument('Language key of a language with an installed custom language file.')) + ), + 'already_installed_language_keys' => $f->list( + $text->simpleDocument( + 'Languages for which this run changed nothing: mode "install" was ' . + 'requested for a language that was already installed (a no-op by design ' . + '- use mode "install_local" to (re-)apply a customizing file instead), ' . + 'or mode "install_local" was requested for an installed language that ' . + 'has no customizing/local file to apply.' + ), + $f->string($text->simpleDocument('Language key of an already installed language.')) + ), + 'not_installed_language_keys' => $f->list( + $text->simpleDocument( + 'Languages requested with mode "install_local" that are not installed - ' . + 'skipped entirely, since there is nothing installed yet to apply local ' . + 'changes on top of.' + ), + $f->string($text->simpleDocument('Language key of a not-yet-installed language.')) + ), + 'invalid_local_language_files' => $f->list( + $text->simpleDocument('Local language files with an invalid file name.'), + $f->string($text->simpleDocument('File name of an invalid local language file.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + $mode = $this->toMode($parameters['mode'] ?? null); + + $currently_installed_language_keys = $this->setup_language->getInstalledLanguages(); + + $to_fully_install = []; + $to_apply_local_changes = []; + $already_installed_no_op = []; + $not_installed_no_op = []; + + foreach ($language_keys as $language_key) { + $is_installed = in_array($language_key, $currently_installed_language_keys, true); + if ($mode === self::MODE_INSTALL) { + if ($is_installed) { + $already_installed_no_op[] = $language_key; + } else { + $to_fully_install[] = $language_key; + } + } elseif ($is_installed) { + $to_apply_local_changes[] = $language_key; + } else { + $not_installed_no_op[] = $language_key; + } + } + + $error_language_keys = []; + foreach ($to_fully_install as $language_key) { + if (!$this->setup_language->checkLanguageForInstallation($language_key)) { + $error_language_keys[] = $language_key; + } + } + + if ($error_language_keys !== []) { + throw new \RuntimeException( + 'Invalid language files: ' . implode(', ', $error_language_keys) + ); + } + + $installed_language_keys = []; + $installed_with_local_language_keys = []; + $invalid_local_language_files = []; + + $affected_language_keys = array_merge($to_fully_install, $to_apply_local_changes); + if ($affected_language_keys !== []) { + $db_languages = $this->setup_language->getAvailableLanguagesForInstallation(); + $local_language_keys = $this->setup_language->getLocalLanguages(); + $invalid_local_language_files = $this->setup_language->getInvalidLocalLanguageFiles( + $affected_language_keys + ); + + foreach ($to_fully_install as $language_key) { + $this->setup_language->flushLanguageForInstallation($language_key); + $this->setup_language->insertLanguageForInstallation($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + + if (in_array($language_key, $local_language_keys, true)) { + $installed_with_local_language_keys[] = $language_key; + } else { + $installed_language_keys[] = $language_key; + } + } + + foreach ($to_apply_local_changes as $language_key) { + $this->setup_language->insertLanguageForApplyingLocalChanges($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + + if (in_array($language_key, $local_language_keys, true)) { + $installed_with_local_language_keys[] = $language_key; + } else { + // "install_local" had nothing to apply: same as "already installed" + $already_installed_no_op[] = $language_key; + } + } + } + + return [ + 'installed_language_keys' => $installed_language_keys, + 'installed_with_local_language_keys' => $installed_with_local_language_keys, + 'already_installed_language_keys' => $already_installed_no_op, + 'not_installed_language_keys' => $not_installed_no_op, + 'invalid_local_language_files' => $invalid_local_language_files, + ]; + } + + private function toMode(mixed $value): string + { + if ($value === self::MODE_INSTALL || $value === self::MODE_INSTALL_LOCAL) { + return $value; + } + + throw new InvalidInputException( + 'mode must be either "' . self::MODE_INSTALL . '" or "' . self::MODE_INSTALL_LOCAL . '".' + ); + } + + /** + * @param array{language_keys: string, mode: string} $grind_result + * @return array{language_keys: list, mode: string} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_keys' => $this->toLanguageKeyList($grind_result['language_keys']), + 'mode' => $this->toMode($grind_result['mode']), + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/InvalidInputException.php b/components/ILIAS/Language/src/Activities/InvalidInputException.php new file mode 100644 index 000000000000..50df9e2898fd --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InvalidInputException.php @@ -0,0 +1,29 @@ +rbac === null) { + throw new \LogicException( + 'RBAC is not available during Setup; ' + . static::class . '::isAllowedToPerform() cannot be used here.' + ); + } + + if (!$this->isKnownUser($usr_id)) { + return false; + } + + return $this->rbac->system()->checkAccessOfUser( + $usr_id, + 'write', + $this->language_folder->getRefId() + ); + } + + /** Never the anonymous user, regardless of its RBAC roles. */ + private function isKnownUser(int $usr_id): bool + { + if ($usr_id <= 0) { + return false; + } + + return !defined('ANONYMOUS_USER_ID') || $usr_id !== ANONYMOUS_USER_ID; + } + + public function maybePerformAs(InputFactory $input_factory, int $usr_id, array $raw_parameters): Result + { + try { + // inside the try: getInputDescription() may throw too; value() throws grind's input error + $parameters = $this->normalizeParameters( + $this->grind($this->getInputDescription($input_factory->field()), $raw_parameters)->value() + ); + if (!$this->isAllowedToPerform($usr_id, $parameters)) { + return new Result\Error( + new \DomainException('You are not allowed to manage the languages of this installation.', 403) + ); + } + + // usr_id last: the trusted value wins over a spoofed form key + return new Result\Ok($this->perform([...$parameters, 'usr_id' => $usr_id])); + } catch (\Throwable $e) { + return new Result\Error(Failure::of($e)); + } + } + + /** @return array */ + abstract protected function normalizeParameters(array $grind_result): array; +} diff --git a/components/ILIAS/Language/src/Activities/LanguageSettings.php b/components/ILIAS/Language/src/Activities/LanguageSettings.php new file mode 100644 index 000000000000..255352d80c61 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/LanguageSettings.php @@ -0,0 +1,35 @@ +settings()->set('lang_detection', $enabled ? '1' : '0'); + } + + public function isPageTranslationEnabled(string $lang_key): bool + { + return (bool) $this->settings()->get('lang_translate_' . $lang_key, '0'); + } + + public function setPageTranslationEnabled(string $lang_key, bool $enabled): void + { + $this->settings()->set('lang_translate_' . $lang_key, $enabled ? '1' : '0'); + } + + private function settings(): Setting + { + global $DIC; + + return $DIC->settings(); + } +} diff --git a/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php new file mode 100644 index 000000000000..158f9ba35f68 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php @@ -0,0 +1,68 @@ +.lang` file names + private const LANGUAGE_KEY_FORMAT = '/^[a-z]{2}$/'; + + /** @return list */ + private function toLanguageKeyList(mixed $value): array + { + if (!is_string($value) && !is_array($value)) { + throw new InvalidInputException('language_keys must be a string or an array of strings.'); + } + + $values = is_array($value) ? $value : [$value]; + $language_keys = []; + + foreach ($values as $item) { + if (!is_string($item)) { + throw new InvalidInputException('language_keys must be a string or an array of strings.'); + } + + foreach (explode(',', (string) $item) as $language_key) { + $language_key = trim($language_key); + if ($language_key === '') { + continue; + } + + if (preg_match(self::LANGUAGE_KEY_FORMAT, $language_key) !== 1) { + throw new InvalidInputException( + 'Invalid language key "' . $language_key . '": a language key must be ' . + 'exactly two lowercase ASCII letters (e.g. "de", "en", "fr").' + ); + } + + if (!in_array($language_key, $language_keys, true)) { + $language_keys[] = $language_key; + } + } + } + + if ($language_keys === []) { + throw new InvalidInputException('At least one language key is required.'); + } + + return $language_keys; + } +} diff --git a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php new file mode 100644 index 000000000000..0bf7fa24a2fa --- /dev/null +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -0,0 +1,149 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Removes all local changes of one or more already installed languages - both +entries edited directly via the "adjust language variables" table and any +override coming from a customizing/local language file - and reinstalls each +language purely from the global/component language files. + +A language is left completely untouched, and reported separately, if it is +not installed (or not a known language key at all), or if its underlying +language file fails validation. +MARKDOWN + ); + } + + protected function inputSectionTitle(): string + { + return 'Remove local language changes'; + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of removing local language changes.'), + [ + 'removed_local_changes_language_keys' => $f->list( + $text->simpleDocument( + 'Already installed languages whose local changes were removed - they were ' . + 'reinstalled purely from the global/component language files.' + ), + $f->string($text->simpleDocument('Language key of a language whose local changes were removed.')) + ), + 'invalid_language_file_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that were left untouched because their underlying ' . + 'language file failed validation.' + ), + $f->string($text->simpleDocument('Language key of a language with an invalid language file.')) + ), + 'not_installed_language_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that were left untouched because they are not ' . + 'installed (or not a known language key at all) - there is nothing to ' . + 'remove local changes from for them.' + ), + $f->string($text->simpleDocument('Language key of a not-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + [$obj_id_by_language_key, $ambiguous_language_keys] = $this->resolveObjIdsByLanguageKey(); + + // checked upfront, so one ambiguous key rejects the whole call before anything is changed + $requested_ambiguous_language_keys = array_values( + array_intersect($language_keys, array_keys($ambiguous_language_keys)) + ); + if ($requested_ambiguous_language_keys !== []) { + throw new AmbiguousLanguageTitleException( + 'Multiple language objects share the title(s) "' + . implode('", "', $requested_ambiguous_language_keys) + . '" - cannot unambiguously resolve which one(s) to remove local changes from.' + ); + } + + $removed_local_changes_language_keys = []; + $invalid_language_file_keys = []; + $not_installed_language_keys = []; + + // not transactional: a failure partway leaves the languages processed so far changed + foreach ($language_keys as $language_key) { + if (!array_key_exists($language_key, $obj_id_by_language_key)) { + $not_installed_language_keys[] = $language_key; + continue; + } + + $obj_id = $obj_id_by_language_key[$language_key]; + + if (!$this->repository->isInstalled($obj_id)) { + $not_installed_language_keys[] = $language_key; + continue; + } + + // removeLocalChanges() also validates the language file: false here means invalid + // (not installed is excluded above) + if ($this->repository->removeLocalChanges($obj_id)) { + $removed_local_changes_language_keys[] = $language_key; + } else { + $invalid_language_file_keys[] = $language_key; + } + } + + return [ + 'removed_local_changes_language_keys' => $removed_local_changes_language_keys, + 'invalid_language_file_keys' => $invalid_language_file_keys, + 'not_installed_language_keys' => $not_installed_language_keys, + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/RepositoryProvider.php b/components/ILIAS/Language/src/Activities/RepositoryProvider.php new file mode 100644 index 000000000000..d664f6735936 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/RepositoryProvider.php @@ -0,0 +1,138 @@ + */ + public function getInstalledLanguages(): array + { + return $this->installed_language_repository->getInstalledLanguages(); + } + + /** @return list */ + public function listLanguageObjects(): array + { + $objects = []; + foreach (\ilObject::_getObjectsByType('lng') as $object) { + $objects[] = ['obj_id' => (int) $object['obj_id'], 'title' => (string) $object['title']]; + } + + return $objects; + } + + public function isSystemLanguage(int $obj_id): bool + { + return $this->languageObject($obj_id)->isSystemLanguage(); + } + + /** Compares against the session language of the current request, not of a given user. */ + public function isUserLanguage(int $obj_id): bool + { + return $this->languageObject($obj_id)->isUserLanguage(); + } + + public function isInstalled(int $obj_id): bool + { + return $this->languageObject($obj_id)->isInstalled(); + } + + /** False if a guard of ilObjLanguage::uninstall() applied and nothing was uninstalled. */ + public function uninstall(int $obj_id): bool + { + return $this->languageObject($obj_id)->uninstall() !== ''; + } + + /** False if the language is not installed or its language file is invalid. */ + public function removeLocalChanges(int $obj_id): bool + { + return $this->languageObject($obj_id)->removeLocalChanges(); + } + + public function replaceLangEntry( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool { + return \ilObjLanguage::replaceLangEntry($module, $identifier, $lang_key, $value, $local_change, $remarks); + } + + /** Writes the entry into the cached module of lng_modules; a missing or broken cache is left alone. */ + public function updateModuleCache(string $lang_key, string $module, string $identifier, string $value): void + { + $set = $this->db->query( + 'SELECT lang_array FROM lng_modules WHERE lang_key = ' + . $this->db->quote($lang_key, 'text') . ' AND module = ' . $this->db->quote($module, 'text') + ); + $row = $this->db->fetchAssoc($set); + if ($row === null || !is_string($row['lang_array'] ?? null)) { + return; + } + + $entries = unserialize($row['lang_array'], ['allowed_classes' => false]); + if (!is_array($entries)) { + return; + } + + $entries[$identifier] = $value; + \ilObjLanguage::replaceLangModule($lang_key, $module, $entries); + } + + public function lookupLogin(int $usr_id): string + { + return \ilObjUser::_lookupLogin($usr_id); + } + + public function setLanguageDetectionEnabled(bool $enabled): void + { + $this->settings->setLanguageDetectionEnabled($enabled); + } + + public function isPageTranslationEnabled(string $lang_key): bool + { + return $this->settings->isPageTranslationEnabled($lang_key); + } + + public function setPageTranslationEnabled(string $lang_key, bool $enabled): void + { + $this->settings->setPageTranslationEnabled($lang_key, $enabled); + } + + private function languageObject(int $obj_id): \ilObjLanguage + { + return new \ilObjLanguage($obj_id); + } +} diff --git a/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php new file mode 100644 index 000000000000..5e13d208ee7e --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php @@ -0,0 +1,56 @@ +, 1: array} + */ + private function resolveObjIdsByLanguageKey(): array + { + $lng_objects = $this->repository->listLanguageObjects(); + + $title_occurrences = []; + foreach ($lng_objects as $lng_object) { + $title_occurrences[$lng_object['title']] = ($title_occurrences[$lng_object['title']] ?? 0) + 1; + } + + $obj_id_by_language_key = []; + $ambiguous_language_keys = []; + foreach ($lng_objects as $lng_object) { + $title = $lng_object['title']; + if ($title_occurrences[$title] > 1) { + $ambiguous_language_keys[$title] = true; + continue; + } + + $obj_id_by_language_key[$title] = (int) $lng_object['obj_id']; + } + + return [$obj_id_by_language_key, $ambiguous_language_keys]; + } +} diff --git a/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php new file mode 100644 index 000000000000..6e2cf0e96834 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php @@ -0,0 +1,98 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Enables or disables the system-wide automatic language detection from the +browser's Accept-Language header. This is a single on/off flag for the +whole installation, not a per-language or per-user setting. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $enabled = $f->checkbox( + 'Enabled', + 'Whether automatic language detection from the browser should be enabled.' + )->withDedicatedName('enabled'); + + return $f->section([ + 'enabled' => $enabled, + ], 'Enable or disable language detection'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of changing the language detection setting.'), + [ + 'enabled' => $f->bool( + $text->simpleDocument('The language detection setting after this change.') + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters) || !array_key_exists('enabled', $parameters) || !is_bool($parameters['enabled'])) { + throw new InvalidInputException('The enabled parameter (bool) is required.'); + } + + $enabled = $parameters['enabled']; + + $this->repository->setLanguageDetectionEnabled($enabled); + + return ['enabled' => $enabled]; + } + + /** + * @param array{enabled: bool} $grind_result + * @return array{enabled: bool} + */ + protected function normalizeParameters(array $grind_result): array + { + return ['enabled' => $grind_result['enabled']]; + } +} diff --git a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php new file mode 100644 index 000000000000..4ed0ffca208c --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php @@ -0,0 +1,143 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Enables or disables the "page translation" feature for one specific +language. This is a per-language on/off flag, not a system-wide setting - +use SetLanguageDetectionEnabled for the system-wide automatic language +detection flag instead. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $language_key = $f->text( + 'Language key', + 'Language key the page translation setting applies to, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_key'); + + $enabled = $f->checkbox( + 'Enabled', + 'Whether page translation should be enabled for this language.' + )->withDedicatedName('enabled'); + + return $f->section([ + 'language_key' => $language_key, + 'enabled' => $enabled, + ], 'Enable or disable page translation'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of changing the page translation setting.'), + [ + 'language_key' => $f->string( + $text->simpleDocument('Language key the page translation setting was changed for.') + ), + 'enabled' => $f->bool( + $text->simpleDocument('The page translation setting for this language after this change.') + ), + 'changed' => $f->bool( + $text->simpleDocument( + 'Whether the setting actually differed from its previously stored value - ' . + 'false if the requested value already matched the stored one, in which case ' . + 'nothing was written.' + ) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters) + || !array_key_exists('language_key', $parameters) + || !is_string($parameters['language_key']) + || trim($parameters['language_key']) === '' + || !array_key_exists('enabled', $parameters) + || !is_bool($parameters['enabled']) + ) { + throw new InvalidInputException( + 'The language_key (non-empty string) and enabled (bool) parameters are required.' + ); + } + + $language_key = trim($parameters['language_key']); + $enabled = $parameters['enabled']; + + if (!in_array($language_key, $this->repository->getInstalledLanguages(), true)) { + throw new InvalidInputException( + 'Unknown language key "' . $language_key . '" - not an installed language.' + ); + } + + $currently_enabled = $this->repository->isPageTranslationEnabled($language_key); + // strict comparison of booleans, a loose `!=` on raw strings misjudges some values (see the test) + $changed = $currently_enabled !== $enabled; + + if ($changed) { + $this->repository->setPageTranslationEnabled($language_key, $enabled); + } + + return [ + 'language_key' => $language_key, + 'enabled' => $enabled, + 'changed' => $changed, + ]; + } + + /** + * @param array{language_key: string, enabled: bool} $grind_result + * @return array{language_key: string, enabled: bool} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_key' => trim($grind_result['language_key']), + 'enabled' => $grind_result['enabled'], + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/UninstallLanguage.php b/components/ILIAS/Language/src/Activities/UninstallLanguage.php new file mode 100644 index 000000000000..c6dfa6942020 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -0,0 +1,159 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Uninstalls one or more already installed languages, flushing their base data +(and any customizing/local data) and resetting the status of the according +language to "not installed". Any user preference still pointing at an +uninstalled language is reset to the system default. + +A language is left completely untouched, and reported separately, if it is +not installed, if it is the current system language, or if it is the +language currently in use by the acting session - none of these may ever be +uninstalled. +MARKDOWN + ); + } + + protected function inputSectionTitle(): string + { + return 'Uninstall languages'; + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of the language uninstallation.'), + [ + 'uninstalled_language_keys' => $f->list( + $text->simpleDocument('Languages that were uninstalled.'), + $f->string($text->simpleDocument('Language key of an uninstalled language.')) + ), + 'system_language_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that were left untouched because they are the ' . + 'current system language - the system language can never be uninstalled.' + ), + $f->string($text->simpleDocument('Language key of the system language.')) + ), + 'user_language_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that were left untouched because they are the ' . + 'language currently in use by the acting session - a language currently ' . + 'in use can never be uninstalled.' + ), + $f->string($text->simpleDocument('Language key of the language currently in use.')) + ), + 'not_installed_language_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that were left untouched because they are not ' . + 'installed (or not a known language key at all) - there is nothing to ' . + 'uninstall for them.' + ), + $f->string($text->simpleDocument('Language key of a not-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + [$obj_id_by_language_key, $ambiguous_language_keys] = $this->resolveObjIdsByLanguageKey(); + + // checked upfront, so one ambiguous key rejects the whole call before anything is uninstalled + $requested_ambiguous_language_keys = array_values( + array_intersect($language_keys, array_keys($ambiguous_language_keys)) + ); + if ($requested_ambiguous_language_keys !== []) { + throw new AmbiguousLanguageTitleException( + 'Multiple language objects share the title(s) "' + . implode('", "', $requested_ambiguous_language_keys) + . '" - cannot unambiguously resolve which one(s) to uninstall.' + ); + } + + $uninstalled_language_keys = []; + $system_language_keys = []; + $user_language_keys = []; + $not_installed_language_keys = []; + + // not transactional: a failure partway leaves the languages processed so far uninstalled + foreach ($language_keys as $language_key) { + if (!array_key_exists($language_key, $obj_id_by_language_key)) { + $not_installed_language_keys[] = $language_key; + continue; + } + + $obj_id = $obj_id_by_language_key[$language_key]; + + if ($this->repository->isSystemLanguage($obj_id)) { + $system_language_keys[] = $language_key; + } elseif ($this->repository->isUserLanguage($obj_id)) { + // compares against this request's session language, not $usr_id: another user's + // language is not protected + $user_language_keys[] = $language_key; + } elseif (!$this->repository->isInstalled($obj_id)) { + $not_installed_language_keys[] = $language_key; + } elseif ($this->repository->uninstall($obj_id)) { + // uninstall() re-checks the guards: its result decides + $uninstalled_language_keys[] = $language_key; + } else { + $not_installed_language_keys[] = $language_key; + } + } + + return [ + 'uninstalled_language_keys' => $uninstalled_language_keys, + 'system_language_keys' => $system_language_keys, + 'user_language_keys' => $user_language_keys, + 'not_installed_language_keys' => $not_installed_language_keys, + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/UpdateLanguage.php b/components/ILIAS/Language/src/Activities/UpdateLanguage.php new file mode 100644 index 000000000000..8a66b2fa0ae6 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -0,0 +1,144 @@ +data->text()->markdown()->simpleDocument( + <<<'MARKDOWN' +Refreshes one or more already installed languages, re-seeding their base +data (plus a customizing/local file if one exists) from the current +language files. A language that is not installed is left completely +untouched - use InstallLanguage to install it first. +MARKDOWN + ); + } + + protected function inputSectionTitle(): string + { + return 'Update languages'; + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->object( + $text->simpleDocument('Result of the language update.'), + [ + 'updated_language_keys' => $f->list( + $text->simpleDocument( + 'Already installed languages that were refreshed from the current ' . + 'language files (base data, plus a customizing/local file if one exists).' + ), + $f->string($text->simpleDocument('Language key of a refreshed language.')) + ), + 'not_installed_language_keys' => $f->list( + $text->simpleDocument( + 'Requested languages that are not installed - skipped entirely, since ' . + 'there is nothing installed yet to refresh. Use InstallLanguage to ' . + 'install them first.' + ), + $f->string($text->simpleDocument('Language key of a not-yet-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + + $currently_installed_language_keys = $this->setup_language->getInstalledLanguages(); + + $to_update = []; + $not_installed_no_op = []; + + foreach ($language_keys as $language_key) { + if (in_array($language_key, $currently_installed_language_keys, true)) { + $to_update[] = $language_key; + } else { + $not_installed_no_op[] = $language_key; + } + } + + $error_language_keys = []; + foreach ($to_update as $language_key) { + if (!$this->setup_language->checkLanguageForInstallation($language_key)) { + $error_language_keys[] = $language_key; + } + } + + if ($error_language_keys !== []) { + throw new \RuntimeException( + 'Invalid language files: ' . implode(', ', $error_language_keys) + ); + } + + if ($to_update !== []) { + $db_languages = $this->setup_language->getAvailableLanguagesForInstallation(); + $local_language_keys = $this->setup_language->getLocalLanguages(); + + foreach ($to_update as $language_key) { + $this->setup_language->flushLanguageForInstallation($language_key); + $this->setup_language->insertLanguageForInstallation($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + } + } + + return [ + 'updated_language_keys' => $to_update, + 'not_installed_language_keys' => $not_installed_no_op, + ]; + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php new file mode 100644 index 000000000000..2dee4f4cf949 --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php @@ -0,0 +1,65 @@ + + */ +class ComponentLanguageFileDirectory implements LanguageFileDirectory +{ + private string $base_directory; + + public function __construct( + private readonly Component $component, + private readonly string $prefix, + private readonly string $path_inside_component = 'lang/' + ) { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + // cached: the location never changes and this is called repeatedly per directory loop + if (!isset($this->base_directory)) { + $reflector = new \ReflectionClass($this->component); + $ilias_base_dir = (string) realpath(__DIR__ . '/../../../../../'); + $this->base_directory = str_replace($ilias_base_dir . '/', '', dirname($reflector->getFileName())); + } + + return $this->base_directory . '/' . $this->path_inside_component; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php new file mode 100644 index 000000000000..e4fecb2ef39f --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php @@ -0,0 +1,46 @@ + + */ +class LanguageFileDirectoryManager +{ + /** + * @var LanguageFileDirectory[] + */ + private array $directories; + + public function __construct( + private readonly LanguageFileDirectory $local_directory, + LanguageFileDirectory ...$global_directories + ) { + $this->directories = $global_directories; + $this->check(); + } + + private function check(): void + { + if ($this->local_directory instanceof CustomizingLanguageFileDirectory) { + if (!$this->local_directory->getPath()) { + throw new \InvalidArgumentException( + "CustomizingLanguageFileDirectory must have a non-empty path" + ); + } + if ($this->local_directory->getPrefix() !== '') { + throw new \InvalidArgumentException( + "CustomizingLanguageFileDirectory must have an empty prefix" + ); + } + } + $main_files = 0; + $prefixes = []; + foreach ($this->directories as $d) { + switch (true) { + case $d instanceof MainLanguageFileDirectory: + $main_files++; + if ($main_files > 1) { + throw new \InvalidArgumentException( + "There must not be more than one MainLanguageFileDirectory" + ); + } + break; + case $d instanceof ComponentLanguageFileDirectory: + if (empty($d->getPrefix())) { + throw new \InvalidArgumentException( + "ComponentLanguageFileDirectory must have a non-empty prefix" + ); + } + if (isset($prefixes[$d->getPrefix()])) { + throw new \InvalidArgumentException( + "There must not be two ComponentLanguageFileDirectory with the same prefix" + ); + } + $prefixes[$d->getPrefix()] = true; + break; + default: + if (empty($d->getPrefix())) { + throw new \InvalidArgumentException("LanguageFileDirectory must have a non-empty prefix"); + } + break; + } + } + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getDirectories(): \Generator + { + yield from $this->directories; + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getCustomizingDirectories(): \Generator + { + yield $this->local_directory; + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getAllDirectories(): \Generator + { + yield from $this->getDirectories(); + yield from $this->getCustomizingDirectories(); + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php new file mode 100644 index 000000000000..5fe11133d0ce --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php @@ -0,0 +1,48 @@ + + */ +class MainLanguageFileDirectory implements LanguageFileDirectory +{ + public function getPrefix(): string + { + return ''; // the files already contain the prefixes. This special case is handled inside ilSetupLanguage + } + + public function getPath(): string + { + return 'lang/'; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } +} diff --git a/components/ILIAS/Language/src/LanguageFolder.php b/components/ILIAS/Language/src/LanguageFolder.php new file mode 100644 index 000000000000..ab8d82f2e01d --- /dev/null +++ b/components/ILIAS/Language/src/LanguageFolder.php @@ -0,0 +1,30 @@ +activity_error_logger` once in its constructor and provide `$this->lng`. + */ +trait RendersActivityErrors +{ + private readonly \ilLogger $activity_error_logger; + + /** + * Turns a maybePerformAs() error into text for setOnScreenMessage() (which renders HTML unescaped): + * - code 403: the localized "no permission" message + * - other 4xx: an answer to the call that may embed caller-controlled data, so this is the one place that escapes it + * - anything else: may carry raw SQL, so only its cause chain is logged and a generic message shown + */ + private function activityErrorMessage(\Throwable $error): string + { + if ($error->getCode() === 403) { + return $this->lng->txt('msg_no_perm_write'); + } + + if ($this->isActivityInputError($error)) { + // ENT_SUBSTITUTE: invalid UTF-8 would make htmlspecialchars() return an empty string + return htmlspecialchars($error->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE); + } + + $this->activity_error_logger->error($this->describeThrowableChain($error)); + + return $this->lng->txt('action_aborted'); + } + + /** A 4xx other than 403: the call itself was rejected, its message is meant for the caller. */ + private function isActivityInputError(\Throwable $error): bool + { + $code = $error->getCode(); + + return is_int($code) && $code >= 400 && $code < 500 && $code !== 403; + } + + /** Includes every getPrevious(), so a wrapper never hides the original. */ + private function describeThrowableChain(\Throwable $error): string + { + $descriptions = []; + for ($current = $error; $current !== null; $current = $current->getPrevious()) { + $descriptions[] = get_class($current) . ': ' . $current->getMessage() . "\n" . $current->getTraceAsString(); + } + + return implode("\nCaused by:\n", $descriptions); + } +} diff --git a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php new file mode 100644 index 000000000000..f2e78b3a73dc --- /dev/null +++ b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php @@ -0,0 +1,41 @@ +directories->getRoot(); + } +} diff --git a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php new file mode 100644 index 000000000000..dc425ac17f2d --- /dev/null +++ b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php @@ -0,0 +1,39 @@ +path; + } +} diff --git a/components/ILIAS/Language/src/Setup/InstallationRoot.php b/components/ILIAS/Language/src/Setup/InstallationRoot.php new file mode 100644 index 000000000000..9b7fdaa64244 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstallationRoot.php @@ -0,0 +1,31 @@ +db ?? throw new \LogicException('No database available for ' . self::class . '.'); + } + + public function getInstalledLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + $query = "SELECT title FROM object_data " . + "WHERE type = " . $ilDB->quote("lng", "text") . " " . + "AND " . $ilDB->like("description", "text", "installed%"); + $r = $ilDB->query($query); + + while ($row = $ilDB->fetchObject($r)) { + $arr[] = $row->title; + } + return $arr; + } + + public function getInstalledLocalLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + $query = "SELECT title FROM object_data " . + "WHERE type = " . $ilDB->quote("lng", "text") . " " . + "AND description = " . $ilDB->quote("installed_local", "text"); + $r = $ilDB->query($query); + + while ($row = $ilDB->fetchObject($r)) { + $arr[] = $row->title; + } + return $arr; + } + + public function getAvailableLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + $query = "SELECT obj_id, title, description 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; + } + + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array + { + $ilDB = $this->db(); + + if ($min_date === "") { + $min_date = "1980-01-01 00:00:00"; + } + if ($max_date === "") { + $max_date = "2200-01-01 00:00:00"; + } + + $q = sprintf( + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s " . + "AND local_change >= %s AND local_change <= %s", + $ilDB->quote($lang_key, "text"), + $ilDB->quote($min_date, "timestamp"), + $ilDB->quote($max_date, "timestamp") + ); + $result = $ilDB->query($q); + + $changes = []; + while ($row = $result->fetchRow(\ilDBConstants::FETCHMODE_ASSOC)) { + $changes[$row["module"]][$row["identifier"]] = $row["value"]; + } + return $changes; + } + + public function getLanguageEntries(string $lang_key): array + { + $ilDB = $this->db(); + + $q = sprintf( + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s", + $ilDB->quote($lang_key, "text") + ); + $result = $ilDB->query($q); + + $entries = []; + while ($row = $result->fetchRow(\ilDBConstants::FETCHMODE_ASSOC)) { + $entries[$row["module"]][$row["identifier"]] = $row["value"]; + } + return $entries; + } + + public function getLocalLanguages(): array + { + $local_langs = []; + $working_dir = getcwd(); + + // chdir() must be undone even on exceptions, see LanguageInstallationManager::insertLanguage() + try { + foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); + if (is_dir($path)) { + $d = dir($path); + chdir($path); + while ($entry = $d->read()) { + if (is_file($entry) && (preg_match("~(^ilias_" . self::LANGUAGE_KEY_PATTERN . "\.lang" . preg_quote($directory->getSuffix(), "~") . "$)~", $entry))) { + $lang_key = substr($entry, 6, 2); + $local_langs[] = $lang_key; + } + } + chdir($working_dir); + } + } + } finally { + chdir($working_dir); + } + + return array_unique($local_langs); + } + + public function getInstallableLanguages(): array + { + $installableLanguages = []; + $working_dir = getcwd(); + + // chdir() must be undone even on exceptions, see LanguageInstallationManager::insertLanguage() + try { + foreach ($this->language_file_directory_manager->getDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); + if (is_dir($path)) { + $d = dir($path); + chdir($path); + while ($entry = $d->read()) { + if (is_file($entry) && (preg_match("~(^ilias_" . self::LANGUAGE_KEY_PATTERN . "\.lang" . preg_quote($directory->getSuffix(), "~") . "$)~", $entry))) { + $lang_key = substr($entry, 6, 2); + $installableLanguages[] = $lang_key; + } + } + chdir($working_dir); + } + } + } finally { + chdir($working_dir); + } + + return array_unique($installableLanguages); + } + + public function getInvalidLocalLanguageFiles(array $language_keys): array + { + $invalid_files = []; + $language_keys = array_values(array_unique($language_keys)); + + foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); + if (!is_dir($path)) { + continue; + } + + $entries = scandir($path); + if ($entries === false) { + continue; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || !is_file($path . DIRECTORY_SEPARATOR . $entry)) { + continue; + } + + foreach ($language_keys as $language_key) { + $expected_file = 'ilias_' . $language_key . '.lang' . $directory->getSuffix(); + if (str_starts_with($entry, 'ilias_' . $language_key . '.') && $entry !== $expected_file) { + $invalid_files[] = $entry; + break; + } + } + } + } + + return array_values(array_unique($invalid_files)); + } + + public function checkLanguage(string $lang_key): bool + { + foreach ($this->language_file_directory_manager->getAllDirectories() as $directory) { + $required = $directory instanceof \ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; + if (!$this->checkLanguageFile($lang_key, $directory, $required)) { + return false; + } + } + + return true; + } + + public function checkLocalLanguageFile(string $lang_key): bool + { + // only the first directory is checked on purpose: getCustomizingDirectories() yields exactly + // one today, and a foreach returning in its first iteration would read like an accidental early return + $directories = iterator_to_array( + $this->language_file_directory_manager->getCustomizingDirectories(), + false + ); + + if ($directories === []) { + return false; + } + + return $this->checkLanguageFile($lang_key, $directories[0], true); + } + + /** + * Validates the lang-file of a single directory for $lang_key: header present and each entry + * has exactly three elements. A missing directory/file is valid unless $required. + */ + private function checkLanguageFile( + string $lang_key, + \ILIAS\Language\ComponentTranslation\LanguageFileDirectory $directory, + bool $required + ): bool { + $working_dir = getcwd(); + $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); + + try { + if (!is_dir($path)) { + return !$required; + } + chdir($path); + + if (!is_file($lang_file)) { + return !$required; + } + + $content = $this->cutHeader(file($lang_file)); + if ($content === false) { + return false; + } + + $prefix = $directory->getPrefix(); + + foreach ($content as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode(self::SEPARATOR, $line); + + if (!empty($prefix)) { + array_unshift($parts, $prefix); + } + if (count($parts) !== 3) { + return false; + } + if (!\ilStr::isUtf8($parts[2])) { + return false; + } + } + } finally { + chdir($working_dir); + } + + return true; + } +} diff --git a/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php b/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php new file mode 100644 index 000000000000..197153ccb662 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php @@ -0,0 +1,68 @@ + including "installed_local" */ + public function getInstalledLanguages(): array; + + /** @return list */ + public function getInstalledLocalLanguages(): array; + + /** @return array keyed by language key */ + public function getAvailableLanguages(): array; + + /** @return array> [module][identifier] => value, only entries with a local_change in the date range */ + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array; + + /** + * Unlike getLocalChanges(), every entry regardless of local_change: seeds a re-seed that only + * reads the customizing directory, see LanguageInstallationManager::insertLanguageForApplyingLocalChanges(). + * + * @return array> [module][identifier] => value + */ + public function getLanguageEntries(string $lang_key): array; + + /** @return list keys with a *.lang.local file */ + public function getLocalLanguages(): array; + + /** @return list keys with an installable main *.lang file */ + public function getInstallableLanguages(): array; + + /** + * Local language files whose names contain a requested key but do not match the naming scheme. + * + * @param list $language_keys + * @return list + */ + public function getInvalidLocalLanguageFiles(array $language_keys): array; + + /** Validates the global, component and customizing *.lang files of $lang_key. */ + public function checkLanguage(string $lang_key): bool; + + /** Unlike checkLanguage(), a missing local file is NOT acceptable here. */ + public function checkLocalLanguageFile(string $lang_key): bool; +} diff --git a/components/ILIAS/Language/src/Setup/LanguageFileParsing.php b/components/ILIAS/Language/src/Setup/LanguageFileParsing.php new file mode 100644 index 000000000000..2fb00d772496 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageFileParsing.php @@ -0,0 +1,49 @@ +getPath(), '/'); + } + + /** + * @param string[] $content + * @return bool|string[] the entries after the header marker, false if there is none + */ + private function cutHeader(array $content) + { + foreach ($content as $key => $val) { + if (trim($val) === "") { + return array_slice($content, $key + 1); + } + } + return false; + } +} diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php new file mode 100644 index 000000000000..e04fa1af6547 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -0,0 +1,393 @@ +now !== null ? ($this->now)() : new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + return $now->format('Y-m-d H:i:s'); + } + + /** + * Installs the given languages, flushes all other known ones. Mirrors ilSetupLanguage::installLanguages(). + * + * @param list $lang_keys + * @param list $local_keys unused, kept for backwards compatibility (as in ilSetupLanguage) + * @return list|bool list of language keys that failed validation, or true + */ + public function installLanguages(array $lang_keys, array $local_keys = []): array|bool + { + $ilDB = $this->db; + + $err_lang = []; + $db_langs = $this->repository->getAvailableLanguages(); + $local_langs = $this->repository->getLocalLanguages(); + + foreach ($lang_keys as $lang_key) { + if ($this->repository->checkLanguage($lang_key)) { + $this->flushLanguage($lang_key, "keep_local"); + $this->insertLanguageForInstallation($lang_key); + + // an already-known language is (re-)registered below + if (!array_key_exists($lang_key, $db_langs)) { + $this->registerInstalledLanguage($lang_key, $db_langs, $local_langs); + } + } else { + $err_lang[] = $lang_key; + } + } + + foreach ($db_langs as $key => $val) { + if (!in_array($key, $err_lang, true)) { + if (in_array($key, $lang_keys, true)) { + $this->registerInstalledLanguage($key, $db_langs, $local_langs); + } 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($this->utcTimestamp(), "timestamp") . " " . + "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " . + "AND type = " . $ilDB->quote("lng", "text"); + $ilDB->manipulate($query); + } + } + } + } + + return ($err_lang) ?: true; + } + + /** + * Registers or updates the object_data row of a (re-)inserted language ("installed" vs. + * "installed_local"); single place for this bookkeeping of installLanguages() and InstallLanguage::perform(). + * + * @param array $known_languages result of + * InstalledLanguageRepository::getAvailableLanguages() + * @param list $local_language_keys result of + * InstalledLanguageRepository::getLocalLanguages() + */ + public function registerInstalledLanguage( + string $lang_key, + array $known_languages, + array $local_language_keys + ): void { + $db = $this->db; + $installation_type = in_array($lang_key, $local_language_keys, true) ? "installed_local" : "installed"; + + if (!array_key_exists($lang_key, $known_languages)) { + $language_id = $db->nextId("object_data"); + $query = "INSERT INTO object_data " . + "(obj_id,type,title,description,owner,create_date,last_update) " . + "VALUES " . + "(" . + $db->quote($language_id, "integer") . "," . + $db->quote("lng", "text") . "," . + $db->quote($lang_key, "text") . "," . + $db->quote($installation_type, "text") . "," . + $db->quote("-1", "integer") . "," . + $db->now() . "," . + $db->now() . + ")"; + $db->manipulate($query); + return; + } + + $query = "UPDATE object_data SET " . + "description = " . $db->quote($installation_type, "text") . ", " . + "last_update = " . $db->quote($this->utcTimestamp(), "timestamp") . " " . + "WHERE obj_id = " . $db->quote($known_languages[$lang_key]["obj_id"], "integer") . " " . + "AND type = " . $db->quote("lng", "text"); + $db->manipulate($query); + } + + public function flushLanguageForInstallation(string $lang_key): void + { + $this->flushLanguage($lang_key, "keep_local"); + } + + public function flushLanguageForUninstallation(string $lang_key): void + { + $this->flushLanguage($lang_key, "all"); + } + + /** + * @param string $mode either "all" or "keep_local" + */ + private function flushLanguage(string $lang_key, string $mode = "all"): void + { + $this->deleteLangData($lang_key, ($mode === "keep_local")); + + if ($mode === "all") { + $this->db->manipulate("DELETE FROM lng_modules WHERE lang_key = " . + $this->db->quote($lang_key, "text")); + $this->translation_cache->invalidate($lang_key); + } + } + + private function deleteLangData(string $lang_key, bool $keep_local_change): void + { + $ilDB = $this->db; + + if (!$keep_local_change) { + $ilDB->manipulate("DELETE FROM lng_data WHERE lang_key = " . + $ilDB->quote($lang_key, "text")); + } else { + $ilDB->manipulate("DELETE FROM lng_data WHERE lang_key = " . + $ilDB->quote($lang_key, "text") . + " AND local_change IS NULL"); + } + } + + public function insertLanguageForInstallation(string $lang_key): void + { + // Every directory the LanguageFileDirectoryManager knows about, + // including the customizing/local one - so an existing custom + // language file is (re-)applied automatically - merged on top of + // whatever local changes are already recorded in the DB. + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getAllDirectories(), + $this->repository->getLocalChanges($lang_key) + ); + } + + /** + * Re-seed a language purely from the global/component language files, + * deliberately leaving out the customizing/local directory. + * + * This is the write path behind "remove local changes": that action + * flushes all data for the language and must reinstall a clean copy - + * if it went through insertLanguageForInstallation() instead, the + * customizing directory's file would immediately be re-applied (and + * re-marked with a fresh local_change timestamp), silently undoing the + * removal it was just asked to perform. + */ + public function insertLanguageForRemovingLocalChanges(string $lang_key): void + { + // Global/component directories only, and an empty seed - a clean + // slate with nothing to preserve or merge. + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getDirectories(), + [] + ); + } + + /** + * Applies only the customizing/local file on top of an installed language, leaving the base data + * untouched: the write path of "Install local" (see InstallLanguage::perform()). The caller flushes nothing. + * + * The seed must hold every stored entry, not only the local changes: insertLanguage() rebuilds the + * lng_modules row of each module from the seed plus the given directories, so a narrower seed would + * silently drop base entries the customizing file does not override. + */ + public function insertLanguageForApplyingLocalChanges(string $lang_key): void + { + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getCustomizingDirectories(), + $this->repository->getLanguageEntries($lang_key) + ); + } + + /** + * Writes whatever data the given $directories/$lang_array seed produce - it does not + * itself decide which directories to read or what to seed with; that is entirely up to + * the caller (see the three call sites above, each of which already states its intent in its + * own name). + * + * @param iterable $directories + * @param array> $lang_array module => identifier => value + */ + private function insertLanguage(string $lang_key, iterable $directories, array $lang_array): void + { + $ilDB = $this->db; + $working_dir = getcwd(); + + // Every exit path below - including an exception thrown out of a DB + // call - must restore the working directory. This method chdir()s + // into each language directory in turn to read its file with a + // relative path; PHP-FPM/mod_php worker processes are long-lived and + // reused across unrelated requests, so a cwd left dangling here (e.g. + // inside lang/customizing/ after an error) would silently corrupt + // relative-path filesystem checks - such as this same class' + // checkLanguageFile() - for every later request handled by that + // worker, for any language, not just this one. This is why a + // language check can spuriously fail for a file that is perfectly + // valid on disk. + try { + $values_sql = []; + + foreach ($directories as $directory) { + $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + + if (!is_dir($path)) { + continue; + } + chdir($path); + + if (!is_file($lang_file)) { + continue; + } + + $content = $this->cutHeader(file($lang_file)); + if (!$content) { + continue; + } + + $prefix = $directory->getPrefix(); + $is_local = $directory->isLocal(); + + // Both of these depend only on the language and on this + // directory's file - never on the individual entry - so they + // are resolved once per directory. Doing it inside the loop + // below meant one "SELECT ... FROM lng_data" per line of the + // file: a customizing file with a few thousand entries issued + // a few thousand identical queries. + $change_date = null; + $newer_db_changes = []; + if ($is_local) { + // a local file overwrites unless the database holds a newer change for the entry + $newer_db_changes = $this->repository->getLocalChanges( + $lang_key, + $this->utcTimestamp(filemtime($lang_file)) + ); + $change_date = $this->utcTimestamp(); + } + + foreach ($content as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $separated = explode(self::SEPARATOR, $line); + + if (!empty($prefix)) { + array_unshift($separated, $prefix); + } + + $pos = strpos($separated[2], self::COMMENT_SEPARATOR); + if ($pos !== false) { + $separated[3] = substr($separated[2], $pos + strlen(self::COMMENT_SEPARATOR)); + $separated[2] = substr($separated[2], 0, $pos); + } + + $module = $separated[0]; + $identifier = $separated[1]; + $value = $separated[2]; + + if (!$is_local) { + // respect recorded local changes; an earlier directory wins + if (isset($lang_array[$module][$identifier])) { + continue; + } + } elseif (isset($newer_db_changes[$module][$identifier])) { + $lang_array[$module][$identifier] = $newer_db_changes[$module][$identifier]; + continue; + } + + $values_sql[] = sprintf( + "(%s,%s,%s,%s,%s,%s)", + $ilDB->quote($module, "text"), + $ilDB->quote($identifier, "text"), + $ilDB->quote($lang_key, "text"), + $ilDB->quote($value, "text"), + $ilDB->quote($change_date, "timestamp"), + $ilDB->quote($separated[3] ?? null, "text") + ); + + $lang_array[$module][$identifier] = $value; + } + } + + if ($values_sql !== []) { + $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES " + . implode(',', $values_sql) + . " ON DUPLICATE KEY UPDATE value=VALUES(value),remarks=VALUES(remarks),local_change=VALUES(local_change);"; + $ilDB->manipulate($query); + } + + if ($lang_array === []) { + return; + } + + $modules = array_keys($lang_array); + $inModulesToDelete = $ilDB->in('module', $modules, false, 'text'); + $ilDB->manipulate(sprintf( + "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete", + $ilDB->quote($lang_key, "text") + )); + + $modulesValuesSql = []; + foreach ($lang_array as $module => $lang_arr) { + $modulesValuesSql[] = sprintf( + "(%s,%s,%s)", + $ilDB->quote($module, "text"), + $ilDB->quote($lang_key, "text"), + $ilDB->quote(serialize($lang_arr), "clob") + ); + } + + $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES " + . implode(',', $modulesValuesSql) + . ";"; + $ilDB->manipulate($query); + $this->translation_cache->invalidate($lang_key); + } finally { + chdir($working_dir); + } + } +} diff --git a/components/ILIAS/Language/src/Setup/NullTranslationCache.php b/components/ILIAS/Language/src/Setup/NullTranslationCache.php new file mode 100644 index 000000000000..e0bbc1b66e3d --- /dev/null +++ b/components/ILIAS/Language/src/Setup/NullTranslationCache.php @@ -0,0 +1,33 @@ +deleteInCache(); + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php new file mode 100644 index 000000000000..91f79a0a1aa8 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -0,0 +1,118 @@ +createDefaultActivity(); + + $this->assertSame($activity::class, (string) $activity->getName()); + } + + public function testGetTypeIsCommand(): void + { + $this->assertSame($this->expectedActivityType(), $this->createDefaultActivity()->getType()); + } + + public function testGetDescriptionReturnsANonEmptyMarkdownDocument(): void + { + $this->assertNotSame('', $this->createDefaultActivity()->getDescription()->getRawRepresentation()); + } + + /** An extra parameter, even optional, is a variance fatal error at class-load time. */ + public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfaceSignature(): void + { + $activity = $this->createDefaultActivity(); + + $interface_method = new ReflectionMethod(Activity::class, 'getInputDescription'); + $own_method = new ReflectionMethod($activity::class, 'getInputDescription'); + + $this->assertSame( + $interface_method->getNumberOfParameters(), + $own_method->getNumberOfParameters(), + 'getInputDescription() must declare exactly as many parameters as Activity::getInputDescription() - ' . + 'no extra (even optional) parameter, or a subclass overriding it with the plain interface ' . + 'signature triggers a PHP fatal error at class-load time.' + ); + $this->assertSame( + (string) $interface_method->getReturnType(), + (string) $own_method->getReturnType() + ); + + foreach ($interface_method->getParameters() as $i => $interface_parameter) { + $own_parameter = $own_method->getParameters()[$i]; + $this->assertSame((string) $interface_parameter->getType(), (string) $own_parameter->getType()); + $this->assertSame($interface_parameter->allowsNull(), $own_parameter->allowsNull()); + } + } +} + +/** + * RepositoryProvider mock answering per obj_id from fake language objects (\ilObjLanguage needs $DIC). + * The factory is only asked on access, so a throwing factory proves no object was reached. + */ +trait LanguageObjectRepositoryProvider +{ + protected function languageObjectProvider(\Closure $lng_objects, \Closure $obj_language_factory): RepositoryProvider&MockObject + { + $provider = $this->createMock(RepositoryProvider::class); + $provider->method('listLanguageObjects')->willReturnCallback($lng_objects); + $provider->method('isSystemLanguage')->willReturnCallback( + static fn(int $id): bool => $obj_language_factory($id)->isSystemLanguage() + ); + $provider->method('isUserLanguage')->willReturnCallback( + static fn(int $id): bool => $obj_language_factory($id)->isUserLanguage() + ); + $provider->method('isInstalled')->willReturnCallback( + static fn(int $id): bool => $obj_language_factory($id)->isInstalled() + ); + $provider->method('uninstall')->willReturnCallback( + static fn(int $id): bool => $obj_language_factory($id)->uninstall() !== '' + ); + $provider->method('removeLocalChanges')->willReturnCallback( + static fn(int $id): bool => $obj_language_factory($id)->removeLocalChanges() + ); + + return $provider; + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php new file mode 100644 index 000000000000..c6911e5a4623 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php @@ -0,0 +1,89 @@ + */ + public static function activityTitleProvider(): array + { + return [ + 'install' => ['install', 'Install languages'], + 'update' => ['update', 'Update languages'], + 'uninstall' => ['uninstall', 'Uninstall languages'], + 'remove local changes' => ['remove', 'Remove local language changes'], + 'add entry' => ['add', 'Add a language entry'], + 'detection' => ['detection', 'Enable or disable language detection'], + 'translation' => ['translation', 'Enable or disable page translation'], + ]; + } + + #[DataProvider('activityTitleProvider')] + public function testGetInputDescriptionIsASectionWithTheExpectedTitle(string $activity, string $title): void + { + $description = $this->activity($activity) + ->getInputDescription($this->createRealFieldsUiFactory()->input()->field()); + + $this->assertInstanceOf(Section::class, $description); + $this->assertSame($title, $description->getLabel()); + } + + private function activity(string $name): LanguageActivity + { + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnArgument(0); + $rbac = $this->rbacOf($this->createMock(\ilRbacSystem::class)); + $folder = $this->languageFolder(); + $setup_language = $this->createMock(\ilSetupLanguage::class); + $repository = $this->createMock(RepositoryProvider::class); + + return match ($name) { + 'install' => new InstallLanguage($rbac, $setup_language, $folder), + 'update' => new UpdateLanguage($rbac, $setup_language, $folder), + 'uninstall' => new UninstallLanguage($rbac, $folder, $repository), + 'remove' => new RemoveLocalLanguageChanges($rbac, $folder, $repository), + 'add' => new AddLanguageEntry($language, $rbac, $folder, $repository), + 'detection' => new SetLanguageDetectionEnabled($rbac, $folder, $repository), + 'translation' => new SetLanguageTranslationEnabled($rbac, $folder, $repository), + default => throw new \InvalidArgumentException($name), + }; + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php new file mode 100644 index 000000000000..1e91ea506173 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php @@ -0,0 +1,50 @@ +createDefaultActivity(); + $result = $activity->perform($this->validPerformParameters()); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + $declared_field_names = []; + foreach ($description->getFields() as $field) { + $declared_field_names[] = $field->getName(); + } + + $this->assertSame($declared_field_names, array_keys($result)); + } +} diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php new file mode 100644 index 000000000000..5f7613628f7c --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -0,0 +1,1193 @@ +createActivity([]); + } + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity([], rbac: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity([], rbac: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): void + { + $repository = new FakeInstalledLanguageRepository(static function (): array { + throw new \LogicException('getInstalledLanguages() must never be called by isAllowedToPerform()'); + }); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \LogicException('replace_lang_entry must never be called by isAllowedToPerform()'); + }; + $update_module_cache = static function (): void { + throw new \LogicException('update_module_cache must never be called by isAllowedToPerform()'); + }; + + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache + ); + + $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsEmptyOnes(): 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]; + return true; + }; + $update_module_cache = static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use (&$calls): void { + $calls['cache'][] = [$lang_key, $module, $identifier, $value]; + }; + $user_login = static fn(int $usr_id): string => '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', + // 'fr' left out entirely - must be treated like a blank value. + ], + '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]); + + // Never touched for the skipped language. + foreach ($calls['replace'] as $call) { + $this->assertNotSame('fr', $call[2]); + } + foreach ($calls['cache'] as $call) { + $this->assertNotSame('fr', $call[0]); + } + } + + /** "Not transactional" is deliberate: languages written before the failing one stay written, 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" stay written: no atomicity across the write loop + $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']); + } + } + + public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNothingForAnyLanguage(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + // 'en' left blank - must reject the whole request. + ], + 'usr_id' => 6, + ]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + // Nothing written for either language - not even the valid "de". + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + // Same de/en rule, exercised via maybePerformAs() with a real UI Factory. + public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void + { + $rbac = $this->createMock(\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 function testWhitespaceOnlyValueIsTreatedAsEmptyAndSkipped(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule tested elsewhere. + $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 translation value (direct perform() caller) counts as empty. */ + public function testNonStringTranslationValueIsTreatedAsEmptyAndSkippedRatherThanCoerced(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule tested elsewhere. + $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 generic 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'], + // 'usr_id' deliberately not present at all. + ]); + + $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]); + } + + 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 valid (see the tests below); only a non-null one 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); + } + + /** A missing or null usr_id with otherwise valid translations succeeds. */ + 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']); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform('not-an-array'); + } + + /** The fake repository returns different installed-language lists for getInputDescription() and perform(). */ + public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call (getInputDescription()): 'fr' not installed yet; 2nd call (perform()): it is + 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', + // no 'fr' key: the form was built before 'fr' was installed + '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 - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + // fail-closed: "en" becomes mandatory only after the form was built without it + public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void + { + $rbac = $this->createMock(\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" too + 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', + // no 'en' key: the form was built before "en" was installed + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + // only "en" is reported missing + $this->assertSame('A value is required for: en.', $error->getMessage()); + // Nothing written for "de" either - fail-closed, not fail-partial. + $this->assertArrayNotHasKey('replace', $calls_to_write_closures); + $this->assertArrayNotHasKey('cache', $calls_to_write_closures); + // Two independent snapshots, not a cached one - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + /** An optional language uninstalled between the two calls: its submitted value is silently dropped. */ + public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void + { + $rbac = $this->createMock(\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', + // a value for "fr" is given: the form still asked for it + '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']); + // "fr"'s given value is silently dropped: it appears in neither list. + $this->assertNotContains('fr', $value['added_language_keys']); + $this->assertNotContains('fr', $value['skipped_empty_language_keys']); + // The remaining, still-installed languages are written normally. + $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 - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndReturnsA403ResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + // getInputDescription() calls txt('meta_l_de') before isAllowedToPerform(), so the mock must allow it too + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback( + static fn(string $key): string => $key === 'msg_no_perm_write' ? 'no write permission' : $key + ); + + $calls = []; + $activity = $this->createActivity( + ['de'], + language: $language, + 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'], + ]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\DomainException::class, $result->error()); + $this->assertSame(403, $result->error()->getCode()); + $this->assertArrayNotHasKey('replace', $calls); + } + + public function testPermissionGrantedPerformsAndReturnsOkResultWithExpectedStructure(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(['de', 'fr'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame('common', $value['module']); + $this->assertSame('new_topic', $value['identifier']); + $this->assertSame(['de'], $value['added_language_keys']); + $this->assertSame(['fr'], $value['skipped_empty_language_keys']); + } + + public function testUsrIdArgumentIsUsedForBothThePermissionCheckAndTheAuditLoginRegardlessOfRawParameters(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(true); + + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'login'; + }; + + $activity = $this->createActivity(['de'], rbac: $rbac, user_login: $user_login); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + // A caller-supplied usr_id must be ignored entirely. + 'usr_id' => 999, + ]); + + $this->assertFalse($result->isError()); + $this->assertSame([6], $login_calls); + } + + public function testDefectFromWithinPerformIsMaskedAsA500ResultErrorKeepingTheOriginal(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \RuntimeException('database write failed'); + }; + + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $replace_lang_entry + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertSame(500, $error->getCode()); + $this->assertStringNotContainsString('database', $error->getMessage()); + $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); + } + + // grind() already rejects a missing 'module', so rbac must never be touched + public function testMissingModuleViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertStringContainsString('module:', $result->error()->getMessage()); + } + + // unlike the top level, a nested group rejects unknown keys outright + public function testMaybePerformAsRejectsAnUnknownLanguageKeyInsideTheNestedTranslationsGroup(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $calls = []; + $activity = $this->createActivity( + ['de'], + 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', + 'xx' => '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: xx', $error->getMessage()); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testMissingRawParametersKeyIsAResultError(): void + { + $activity = $this->createActivity(['de']); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + /** Rejected by grind() itself (non-array 'translations', non-string key) as InvalidInputException. */ + public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array + { + return [ + 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope']], + 'translations with non-string key' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => [0 => 'Hallo']], + ], + ]; + } + + #[DataProvider('invalidRawParametersRejectedByGrindWithStringErrorProvider')] + public function testMaybePerformAsRejectsStructurallyInvalidTranslationsAtGrindLevelWithoutCheckingPermission( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersProvider(): array + { + return [ + // valid 'de' lets grind() succeed, so the blank module/identifier is caught by toNonEmptyString() + 'blank module' => [['module' => ' ', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo']]], + 'blank identifier' => [['module' => 'common', 'identifier' => ' ', 'translations' => ['de' => 'Hallo']]], + // grind() converts the UI framework's blank \InvalidArgumentException into an InvalidInputException + 'translations with non-string value' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 42]], + ], + ]; + } + + #[DataProvider('invalidRawParametersProvider')] + public function testMaybePerformAsRejectsInvalidRawParametersAsResultError(array $raw_parameters): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslationsAsGiven(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = []; + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls) + ); + + $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => ' common ', + 'identifier' => ' new_topic ', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertSame('common', $calls['replace'][0][0]); + $this->assertSame('new_topic', $calls['replace'][0][1]); + } + + /** An empty translations array is legal: every installed language is skipped, not rejected. */ + public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLanguage(): void + { + // 'fr'/'it' avoid the separate 'de'/'en' mandatory-value rule tested elsewhere. + $rbac = $this->createMock(\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']); + } + + /** + * A PHP variance fatal is not catchable, hence a separate process: a regression fails this test instead of + * crashing the suite. + */ + #[RunInSeparateProcess] + public function testSubclassOverridingGetInputDescriptionWithThePlainInterfaceSignatureDoesNotTriggerAFatalError(): void + { + // txt() must be stubbed (called per translation field); createActivity() can't be reused because of the inline subclass + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback(static fn(string $k): string => $k); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->method('getInstalledLanguages')->willReturn(['de']); + + $activity = new class ( + $language, + $this->rbacOf($this->createMock(\ilRbacSystem::class)), + $this->languageFolder(), + $repository, + ) extends AddLanguageEntry { + public function getInputDescription(FieldFactory $f): FormInput + { + return parent::getInputDescription($f); + } + }; + + $this->assertInstanceOf( + FormInput::class, + $activity->getInputDescription($this->createRealFieldsUiFactory()->input()->field()) + ); + } + + public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslationFields(): void + { + $module_text = $this->createMock(Text::class); + $module_text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $module_text->expects($this->once())->method('withDedicatedName')->with('module')->willReturnSelf(); + + $identifier_text = $this->createMock(Text::class); + $identifier_text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $identifier_text->expects($this->once())->method('withDedicatedName')->with('identifier')->willReturnSelf(); + + $de_text = $this->createMock(Text::class); + $de_required = null; + $de_text->method('withRequired')->willReturnCallback(function (bool $required) use ($de_text, &$de_required) { + $de_required = $required; + return $de_text; + }); + $de_text->expects($this->once())->method('withDedicatedName')->with('de')->willReturnSelf(); + + $fr_text = $this->createMock(Text::class); + $fr_required = null; + $fr_text->method('withRequired')->willReturnCallback(function (bool $required) use ($fr_text, &$fr_required) { + $fr_required = $required; + return $fr_text; + }); + $fr_text->expects($this->once())->method('withDedicatedName')->with('fr')->willReturnSelf(); + + $text_calls = []; + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->method('text')->willReturnCallback( + function (string $label, ?string $byline = null) use (&$text_calls, $module_text, $identifier_text, $de_text, $fr_text) { + $text_calls[] = $label; + return match ($label) { + 'Module' => $module_text, + 'Identifier' => $identifier_text, + 'meta_l_de' => $de_text, + 'meta_l_fr' => $fr_text, + default => throw new \LogicException("unexpected text() label: $label"), + }; + } + ); + + $translations_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); + $translations_group->expects($this->once())->method('withDedicatedName')->with('translations')->willReturnSelf(); + + $outer_section = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class); + + $group_calls = []; + $field->method('group')->willReturnCallback( + function (array $fields, ?string $label = null, ?string $byline = null) use ( + &$group_calls, + $de_text, + $fr_text, + $module_text, + $identifier_text, + $translations_group + ) { + $group_calls[] = $fields; + if ($fields === ['de' => $de_text, 'fr' => $fr_text]) { + return $translations_group; + } + throw new \LogicException('unexpected group() call'); + } + ); + + $section_calls = []; + $field->method('section')->willReturnCallback( + function (array $fields, string $label) use (&$section_calls, $module_text, $identifier_text, $translations_group, $outer_section) { + $section_calls[] = [$fields, $label]; + if ($fields === ['module' => $module_text, 'identifier' => $identifier_text, 'translations' => $translations_group]) { + return $outer_section; + } + throw new \LogicException('unexpected section() call'); + } + ); + + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback(static fn(string $key): string => $key); + + $activity = $this->createActivity( + ['de', 'fr'], + language: $language + ); + + $this->assertSame($outer_section, $activity->getInputDescription($field)); + $this->assertSame('Add a language entry', $section_calls[0][1]); + $this->assertSame(['Module', 'Identifier', 'meta_l_de', 'meta_l_fr'], $text_calls); + // only 'de' is required among translations + $this->assertTrue($de_required); + $this->assertFalse($fr_required); + } + + public function testOutputDescriptionDeclaresTheExpectedFourFields(): void + { + $activity = $this->createActivity(['de']); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + + $field_names = []; + foreach ($description->getFields() as $field) { + $field_names[] = $field->getName(); + } + + $this->assertSame( + ['module', 'identifier', 'added_language_keys', 'skipped_empty_language_keys'], + $field_names + ); + } + + /** + * @param array $calls + */ + private function spyReplaceLangEntry(array &$calls): \Closure + { + return 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]; + return true; + }; + } + + /** + * @param array $calls + */ + private function spyUpdateModuleCache(array &$calls): \Closure + { + return static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use (&$calls): void { + $calls['cache'][] = [$lang_key, $module, $identifier, $value]; + }; + } + + /** + * @param list $installed_languages + */ + private function createActivity( + array $installed_languages, + ?\ilRbacSystem $rbac = null, + ?Language $language = null, + ?InstalledLanguageRepository $installed_language_repository = null, + int $language_folder_ref_id = 0, + ?\Closure $replace_lang_entry = null, + ?\Closure $update_module_cache = null, + ?\Closure $user_login = null, + ): AddLanguageEntry { + // no-op defaults, as the real \ilObjLanguage closures need $DIC; user_login() always runs in perform() + $replace_lang_entry ??= static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => true; + $update_module_cache ??= static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ): void { + }; + $user_login ??= static fn(int $usr_id): string => 'default-login'; + $installed_language_repository ??= new FakeInstalledLanguageRepository(static fn(): array => $installed_languages); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->method('getInstalledLanguages')->willReturnCallback( + static fn(): array => $installed_language_repository->getInstalledLanguages() + ); + $repository->method('replaceLangEntry')->willReturnCallback($replace_lang_entry); + $repository->method('updateModuleCache')->willReturnCallback($update_module_cache); + $repository->method('lookupLogin')->willReturnCallback($user_login); + + return new AddLanguageEntry( + $language ?? $this->createMock(Language::class), + $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), + $this->languageFolder($language_folder_ref_id), + $repository, + ); + } +} + +/** + * In-memory stand-in for InstalledLanguageRepository; only getInstalledLanguages() is reachable, the rest throws. + */ +final class FakeInstalledLanguageRepository implements InstalledLanguageRepository +{ + public function __construct( + private readonly \Closure $installed_languages, + ) { + } + + public function getInstalledLanguages(): array + { + return ($this->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/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/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php new file mode 100644 index 000000000000..0b4f3ce59d81 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -0,0 +1,249 @@ +expectException(\LogicException::class); + + $this->host()->callGrind($this->createMock(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/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php new file mode 100644 index 000000000000..ec92890e4729 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -0,0 +1,593 @@ +createSetupLanguageMock(['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 testSingleNewLanguageIsInstalled(): void + { + $setup_language = $this->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']); + } + + public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUnderInstallMode(): void + { + // Mode "install": 'de' is already installed and must be left + // completely alone (no validation, no write at all) - only the + // not-yet-installed 'fr' is actually 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([ + // Duplicate 'de' across the comma-separated string and the array + // must still be deduplicated exactly like before. + '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 "was 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" on an installed language is a complete no-op: re-applying the local file is "install_local"'s job. */ + 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->never())->method('checkLanguageForInstallation'); + $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']); + } + + /** "install_local" without a local file is reported as "already installed": nothing 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->never())->method('checkLanguageForInstallation'); + $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 is a 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 language is a no-op, perform() must not read the available/local languages or scan 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']); + } + + /** Every state in one request lands in its 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') + // the already-installed 'de' is untouched + ->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->never())->method('checkLanguageForInstallation'); + $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') + // the not-installed 'fr' is left untouched + ->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']); + } + + 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->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + + $this->expectException(\RuntimeException::class); + $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->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + + $this->expectException(\RuntimeException::class); + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de,xx', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + } + + public function testMissingModeParameterIsRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform([ + 'language_keys' => 'de', + ]); + } + + public function testInvalidModeValueIsRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->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->createMock(\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->createSetupLanguageMock([], [], []) + ); + + $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->createSetupLanguageMock([], [], []))->perform($parameters); + } + + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->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->createMock(\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()); + } + + private function createActivity( + ilSetupLanguage $setup_language, + ?\ilRbacSystem $rbac = null, + ): InstallLanguage { + return new InstallLanguage( + $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), + $setup_language, + $this->languageFolder() + ); + } + + private 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); + $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); + + return $setup_language; + } +} diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php new file mode 100644 index 000000000000..908818724509 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -0,0 +1,85 @@ +}> + */ + 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']], + $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..4b8e490ef382 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -0,0 +1,246 @@ +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->createMock(\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']); + } +} + +final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity +{ + public function __construct( + RBAC $rbac, + LanguageFolder $language_folder, + private readonly bool $throw_type_error = false, + ) { + parent::__construct($rbac, $language_folder); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->data->text()->markdown()->simpleDocument('Test double for LanguageActivity::maybePerformAs().'); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + if ($this->throw_type_error) { + throw new \TypeError('simulated TypeError while building the input description'); + } + + throw new \RuntimeException('simulated failure while building the input description'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + public function perform(mixed $parameters): mixed + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + protected function normalizeParameters(array $grind_result): array + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } +} + +/** + * normalizeParameters() always returns a colliding 'usr_id' (spoofed form input); perform() returns $parameters + * unchanged so the test can inspect the merge. + */ +final class LanguageActivityPerformEchoTestDouble extends LanguageActivity +{ + public function __construct( + RBAC $rbac, + LanguageFolder $language_folder, + private readonly ?FormInput $description = null, + ) { + parent::__construct($rbac, $language_folder); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->data->text()->markdown()->simpleDocument('Test double for the usr_id precedence in maybePerformAs().'); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + return $this->description ?? $f->group([]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + public function perform(mixed $parameters): mixed + { + return $parameters; + } + + /** + * @return array + */ + protected function normalizeParameters(array $grind_result): array + { + // spoofed 'usr_id' that must lose against the one maybePerformAs() passes + return ['usr_id' => 'SPOOFED-VALUE', 'source' => 'form']; + } +} diff --git a/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php b/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php new file mode 100644 index 000000000000..34166505313a --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php @@ -0,0 +1,92 @@ +dic_backup = $GLOBALS['DIC'] ?? null; + } + + protected function tearDown(): void + { + $GLOBALS['DIC'] = $this->dic_backup; + } + + private function withSettings(\ilSetting $settings): LanguageSettingsLegacyProxy + { + $dic = new \ILIAS\DI\Container(); + $dic['ilSetting'] = static fn(): \ilSetting => $settings; + $GLOBALS['DIC'] = $dic; + + return new LanguageSettingsLegacyProxy(); + } + + public function testDoesNotTouchTheLegacyContainerOnConstruction(): void + { + unset($GLOBALS['DIC']); + + $this->assertInstanceOf(LanguageSettingsLegacyProxy::class, new LanguageSettingsLegacyProxy()); + } + + /** @return array */ + public static function storedFlagProvider(): array + { + return ['"1"' => ['1', true], '"0"' => ['0', false], 'empty string' => ['', false]]; + } + + /** Falsy cast of the stored string, like ilObjLanguageAccess::_checkTranslate(), under the per-language key. */ + #[DataProvider('storedFlagProvider')] + public function testIsPageTranslationEnabledCastsTheStoredValueUnderThePerLanguageKey(string $stored, bool $expected): void + { + $settings = $this->createMock(\ilSetting::class); + $settings->expects($this->once())->method('get')->with('lang_translate_fr', '0')->willReturn($stored); + + $this->assertSame($expected, $this->withSettings($settings)->isPageTranslationEnabled('fr')); + } + + public function testSettingsAreWrittenAsOneAndZeroUnderTheirKeys(): void + { + $written = []; + $settings = $this->createMock(\ilSetting::class); + $settings->method('set')->willReturnCallback( + static function (string $key, string $value) use (&$written): void { + $written[] = [$key, $value]; + } + ); + + $proxy = $this->withSettings($settings); + $proxy->setLanguageDetectionEnabled(true); + $proxy->setLanguageDetectionEnabled(false); + $proxy->setPageTranslationEnabled('de', true); + $proxy->setPageTranslationEnabled('de', false); + + $this->assertSame( + [['lang_detection', '1'], ['lang_detection', '0'], ['lang_translate_de', '1'], ['lang_translate_de', '0']], + $written + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php new file mode 100644 index 000000000000..fba29a700c6a --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php @@ -0,0 +1,66 @@ +input()->field()` returns REAL field objects, so that + * GrindsFormInput::grind() can run withNameFrom()/withInput()/getContent() on them; a plain mock would turn + * every call into a Result\Error. Same recipe as CommonFieldRendering::getFieldFactory() in the UI tests. + */ +trait RealFieldsUiFactory +{ + /** + * `input()` is a mock returning the real field factory from `field()`, wired against real + * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory`; `txt()` of the Language mock echoes the key. + */ + protected function createRealFieldsUiFactory(): \ILIAS\UI\Factory + { + $data_factory = new DataFactory(); + $lng = $this->createMock(Language::class); + $lng->method('txt')->willReturnArgument(0); + $refinery = new RefineryFactory($data_factory, $lng); + + $field_factory = new FieldFactoryImpl( + $this->createMock(NodeFactory::class), + $this->createMock(UploadLimitResolver::class), + new SignalGenerator(), + $data_factory, + $refinery, + $lng + ); + + $input = $this->createMock(\ILIAS\UI\Component\Input\Factory::class); + $input->method('field')->willReturn($field_factory); + + $ui = $this->createMock(\ILIAS\UI\Factory::class); + $ui->method('input')->willReturn($input); + + return $ui; + } +} diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php new file mode 100644 index 000000000000..d73a56eb5b24 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -0,0 +1,577 @@ +buildFakeLanguageWorld(['de' => []]); + + return $this->createActivity($lng_objects, $obj_language_factory); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + /** + * @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 FakeRemoveLocalLanguageChangesObject( + is_installed: $flags['installed'] ?? true, + remove_local_changes_return_value: $flags['remove_local_changes_return'] ?? true, + ); + $obj_id++; + } + + $lng_objects = static fn(): array => $lng_objects_list; + $obj_language_factory = static fn(int $id): FakeRemoveLocalLanguageChangesObject => $fakes_by_obj_id[$id]; + + return [$fakes_by_obj_id, $lng_objects, $obj_language_factory]; + } + + public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void + { + $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'); + }; + + $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 ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ + 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 ambiguity surfaces as Result\Error and the object factory is never reached. */ + public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void + { + $rbac = $this->createMock(\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 does not poison unrelated, unambiguous ones. */ + 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 FakeRemoveLocalLanguageChangesObject(is_installed: true); + $obj_language_factory = static function (int $id) use ($fr): FakeRemoveLocalLanguageChangesObject { + 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['removed_local_changes_language_keys']); + $this->assertSame(1, $fr->removeLocalChangesCallCount()); + } + + /** The ambiguity check covers all requested keys before any change: 'de' must not be changed when 'fr' is ambiguous. */ + public function testAnUnambiguousKeyBeforeAnAmbiguousOneNeverHasLocalChangesRemovedOnceTheWholeRequestIsRejected(): void + { + $de = new FakeRemoveLocalLanguageChangesObject(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): FakeRemoveLocalLanguageChangesObject { + $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' first on purpose + $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, the check ran before the per-key loop + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->removeLocalChangesCallCount()); + } + } + + /** Same, end-to-end through maybePerformAs(). */ + public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $de = new FakeRemoveLocalLanguageChangesObject(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): FakeRemoveLocalLanguageChangesObject { + 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->removeLocalChangesCallCount()); + } + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac, + 42 + ); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac + ); + + $this->assertFalse($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + /** isAllowedToPerform() is a pure permission check: no object access, no removeLocalChanges(). */ + public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): void + { + $lng_objects = static function (): array { + throw new \LogicException('lng_objects must never be called by isAllowedToPerform()'); + }; + $obj_language_factory = static function (int $id) { + throw new \LogicException('obj_language_factory must never be called by isAllowedToPerform()'); + }; + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity($lng_objects, $obj_language_factory, $rbac); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + public function testInstalledLanguageWithValidFileHasItsLocalChangesRemoved(): 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['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalledWithoutCallingRemoveLocalChanges(): 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['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + $this->assertSame(0, $fakes[1]->removeLocalChangesCallCount()); + } + + /** removeLocalChanges() returning false (invalid language file) is reported as invalid_language_file_keys. */ + public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInvalidLanguageFile(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['remove_local_changes_return' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['removed_local_changes_language_keys']); + $this->assertSame(['de'], $result['invalid_language_file_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + /** 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) { + $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['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame(['xx'], $result['not_installed_language_keys']); + $this->assertSame([], $factory_calls); + } + + /** Every state in one request lands in its own bucket; removeLocalChanges() only runs for the two installed ones. */ + public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => ['remove_local_changes_return' => false], + 'it' => ['installed' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => ['de', 'fr', 'it', 'xx'], + ]); + + $this->assertSame(['de'], $result['removed_local_changes_language_keys']); + $this->assertSame(['fr'], $result['invalid_language_file_keys']); + $this->assertSame(['it', 'xx'], $result['not_installed_language_keys']); + + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount(), 'de must have its local changes removed'); + $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount(), 'fr is installed, so must still be attempted'); + $this->assertSame(0, $fakes[3]->removeLocalChangesCallCount(), 'not-installed language must never be attempted'); + } + + 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['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + public function testCommaSeparatedStringIsNormalizedIntoMultipleLanguageKeys(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => [], + 'it' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => ' de, fr ,it', + ]); + + $this->assertSame(['de', 'fr', 'it'], $result['removed_local_changes_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']]]], + 'non-array, non-string value' => [['language_keys' => 42]], + '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, which would pass silently. */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); + } + + /** Denied permission short-circuits before perform(): no object is touched. */ + public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges(): 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]->removeLocalChangesCallCount()); + } + + public function testPermissionGrantedPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\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()['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ + public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalChangesForEachOne(): void + { + $rbac = $this->createMock(\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()['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount()); + } + + /** A Throwable from perform() becomes a Result\Error. */ + public function testThrowableFromPerformIsTurnedIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $lng_objects = static fn(): array => []; + $obj_language_factory = static fn(int $id) => null; + + // an empty language_keys value throws inside perform(), after the permission check passed + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ' , ']); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + /** A missing 'language_keys' fails the required Text field in grind(), before rbac is touched. */ + public function testMissingLanguageKeysParameterViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $result = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + private function createActivity( + \Closure $lng_objects, + \Closure $obj_language_factory, + ?\ilRbacSystem $rbac = null, + int $language_folder_ref_id = 0 + ): RemoveLocalLanguageChanges { + return new RemoveLocalLanguageChanges( + $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), + $this->languageFolder($language_folder_ref_id), + $this->languageObjectProvider($lng_objects, $obj_language_factory) + ); + } +} + +/** + * Stand-in for \ilObjLanguage (see above); counts removeLocalChanges() calls. + */ +final class FakeRemoveLocalLanguageChangesObject +{ + private int $remove_local_changes_calls = 0; + + /** + * @param bool $remove_local_changes_return_value false mimics a language file failing check() after isInstalled() passed + */ + public function __construct( + private readonly bool $is_installed, + private readonly bool $remove_local_changes_return_value = true, + ) { + } + + public function isInstalled(): bool + { + return $this->is_installed; + } + + public function removeLocalChanges(): bool + { + $this->remove_local_changes_calls++; + + return $this->remove_local_changes_return_value; + } + + public function removeLocalChangesCallCount(): int + { + return $this->remove_local_changes_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php new file mode 100644 index 000000000000..dc512e5cdad9 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php @@ -0,0 +1,118 @@ +createMock(InstalledLanguageRepository::class); + $installed->expects($this->once())->method('getInstalledLanguages')->willReturn(['de', 'en']); + + $provider = new RepositoryProvider($installed, $this->createMock(\ilDBInterface::class), $this->createMock(LanguageSettings::class)); + + $this->assertSame(['de', 'en'], $provider->getInstalledLanguages()); + } + + /** @return array}> */ + public static function unusableCacheRowProvider(): array + { + return [ + 'no lng_modules row' => [null], + 'lang_array is NULL' => [['lang_array' => null]], + 'lang_array is not a string' => [['lang_array' => 42]], + 'lang_array decodes to a scalar' => [['lang_array' => serialize('not-an-array')]], + 'lang_array is broken serialize' => [['lang_array' => 'a:1:{broken']], + 'lang_array is an object (allowed_classes false)' => [['lang_array' => serialize(new \ArrayObject(['x' => 'y']))]], + ]; + } + + /** + * Each of these ends before the write to \ilObjLanguage::replaceLangModule(), which needs the legacy container: + * reaching it would fail with an Error instead of returning. + * + * @param ?array $row + */ + #[DataProvider('unusableCacheRowProvider')] + public function testUpdateModuleCacheLeavesAMissingOrBrokenCacheAlone(?array $row): void + { + $statement = $this->createMock(\ilDBStatement::class); + $db = $this->createMock(\ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value, string $type): string => "'" . $value . "'"); + $db->method('query')->willReturn($statement); + $db->method('fetchAssoc')->willReturn($row); + + // unserialize() reports broken input as a notice, which is not what this test is about + set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING); + try { + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) + ->updateModuleCache('de', 'common', 'topic', 'value'); + } finally { + restore_error_handler(); + } + + $this->addToAssertionCount(1); + } + + public function testUpdateModuleCacheQuotesLanguageKeyAndModuleIntoTheQuery(): void + { + $db = $this->createMock(\ilDBInterface::class); + $db->expects($this->exactly(2))->method('quote')->willReturnCallback( + static fn(mixed $value, string $type): string => "<$type:$value>" + ); + $db->expects($this->once())->method('query')->with( + 'SELECT lang_array FROM lng_modules WHERE lang_key = AND module = ' + ); + $db->method('fetchAssoc')->willReturn(null); + + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) + ->updateModuleCache('de', "co'mmon", 'topic', 'value'); + } + + public function testSettingsAreDelegatedToTheLanguageSettings(): void + { + $settings = $this->createMock(LanguageSettings::class); + $settings->expects($this->once())->method('setLanguageDetectionEnabled')->with(true); + $settings->expects($this->once())->method('isPageTranslationEnabled')->with('fr')->willReturn(true); + $settings->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); + + $provider = new RepositoryProvider( + $this->createMock(InstalledLanguageRepository::class), + $this->createMock(\ilDBInterface::class), + $settings + ); + $provider->setLanguageDetectionEnabled(true); + $this->assertTrue($provider->isPageTranslationEnabled('fr')); + $provider->setPageTranslationEnabled('de', false); + } +} diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php new file mode 100644 index 000000000000..981e72fb27a8 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -0,0 +1,349 @@ +createActivity(); + } + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity(rbac_system: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + /** isAllowedToPerform() is a pure permission check: no Setting access. */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, repository: $repository); + + $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + public function testPerformWithEnabledTrueWritesOneIntoLangDetectionSettingAndReturnsEnabledTrue(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once()) + ->method('setLanguageDetectionEnabled')->with(true); + + $result = $this->createActivity(repository: $repository)->perform(['enabled' => true]); + + $this->assertSame(['enabled' => true], $result); + } + + public function testPerformWithEnabledFalseWritesZeroIntoLangDetectionSettingAndReturnsEnabledFalse(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once()) + ->method('setLanguageDetectionEnabled')->with(false); + + $result = $this->createActivity(repository: $repository)->perform(['enabled' => false]); + + $this->assertSame(['enabled' => false], $result); + } + + public function testPerformRejectsMissingParametersArray(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(repository: $repository)->perform([]); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(repository: $repository)->perform('not-an-array'); + } + + /** perform() requires a strict bool for 'enabled'; truthy/falsy look-alikes ("1", 1, ...) are rejected. */ + #[DataProvider('nonBooleanEnabledValuesProvider')] + public function testPerformRejectsNonStrictBooleanEnabledValues(mixed $value): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(repository: $repository)->perform(['enabled' => $value]); + } + + public static function nonBooleanEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 1' => [1], + 'int 0' => [0], + 'float 1.0' => [1.0], + 'null' => [null], + 'array' => [[true]], + ]; + } + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('setLanguageDetectionEnabled')->with(true); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** The permission check is a real gate: a denied "write" yields a Result\Error and leaves the Setting untouched. */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertTrue($result->isError()); + } + + public function testMaybePerformAsRejectsAnEnabledValueOutsideTheCheckboxWhitelistAsA400WithoutTouchingSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => 'not-a-bool']); + + $this->assertTrue($result->isError()); + // outside the whitelist of normalizeCheckboxRawValue(): grind() turns it into an InvalidInputException + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('setLanguageDetectionEnabled')->with(false); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository)->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => false], $result->value()); + } + + public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'int 1' => [1], + ]; + } + + public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array + { + return [ + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 0' => [0], + 'null' => [null], + ]; + } + + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsAndWritesEnabledTrue(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('setLanguageDetectionEnabled')->with(true); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** Same for "false". */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('setLanguageDetectionEnabled')->with(false); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => false], $result->value()); + } + + public static function rejectedEnabledValuesProvider(): array + { + return [ + 'float 1.0' => [1.0], + 'array' => [[true]], + ]; + } + + /** + * Values outside the whitelist of GrindsFormInput::normalizeCheckboxRawValue() are still rejected. + */ + #[DataProvider('rejectedEnabledValuesProvider')] + public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWithoutTouchingSettings(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('setLanguageDetectionEnabled'); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void + { + $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('checkbox') + ->with( + 'Enabled', + 'Whether automatic language detection from the browser should be enabled.' + ) + ->willReturn($checkbox); + + $group = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class); + $field->expects($this->once()) + ->method('section') + ->with(['enabled' => $checkbox], $this->anything()) + ->willReturn($group); + + $activity = $this->createActivity(); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void + { + $bool_description = $this->createMock(Description::class); + $object_description = $this->createMock(Description::class); + + $f = $this->createMock(\ILIAS\Data\Description\Factory::class); + $f->expects($this->once())->method('bool')->willReturn($bool_description); + $f->expects($this->once()) + ->method('object') + ->with($this->anything(), ['enabled' => $bool_description]) + ->willReturn($object_description); + + $this->assertSame($object_description, $this->createActivity()->getOutputDescription($f)); + } + + private function createActivity( + ?\ilRbacSystem $rbac_system = null, + ?RepositoryProvider $repository = null, + int $language_folder_ref_id = 0 + ): SetLanguageDetectionEnabled { + return new SetLanguageDetectionEnabled( + $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)), + $this->languageFolder($language_folder_ref_id), + $repository ?? $this->createMock(RepositoryProvider::class) + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php new file mode 100644 index 000000000000..a02344ee0365 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -0,0 +1,540 @@ +" setting lives behind RepositoryProvider). + * A denied "write" permission must prevent both the outcome and the Settings access. + */ +class SetLanguageTranslationEnabledTest extends ActivityContractTestCase +{ + protected function createDefaultActivity(): SetLanguageTranslationEnabled + { + return $this->createActivity(); + } + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity(rbac_system: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + /** isAllowedToPerform() is a pure permission check: no Setting access. */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, repository: $repository); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + /** Only a difference between the stored and the requested value is written. */ + public static function performChangedDecisionProvider(): array + { + return [ + 'stored off, request on -> changed, write on' => [false, true, true, true], + 'stored off, request off -> unchanged, no write' => [false, false, false, null], + 'stored on, request on -> unchanged, no write' => [true, true, false, null], + 'stored on, request off -> changed, write off' => [true, false, true, false], + ]; + } + + #[DataProvider('performChangedDecisionProvider')] + public function testPerformWritesOnlyWhenTheRequestedValueDiffersFromTheStoredOne( + bool $stored_value, + bool $requested_enabled, + bool $expected_changed, + ?bool $expected_written_value + ): void { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once()) + ->method('isPageTranslationEnabled') + ->with('de') + ->willReturn($stored_value); + + if ($expected_written_value === null) { + $repository->expects($this->never())->method('setPageTranslationEnabled'); + } else { + $repository->expects($this->once()) + ->method('setPageTranslationEnabled') + ->with('de', $expected_written_value); + } + + $result = $this->createActivity(repository: $repository) + ->perform(['language_key' => 'de', 'enabled' => $requested_enabled]); + + $this->assertSame( + ['language_key' => 'de', 'enabled' => $requested_enabled, 'changed' => $expected_changed], + $result + ); + } + + /** Pins the key "lang_translate_" and the default '0'. */ + public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once()) + ->method('isPageTranslationEnabled')->with('fr')->willReturn(false); + + $this->createActivity(repository: $repository)->perform(['language_key' => 'fr', 'enabled' => false]); + } + + /** perform() re-implements its own trim() of language_key. */ + public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void + { + $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(repository: $repository) + ->perform(['language_key' => ' de ', 'enabled' => true]); + + $this->assertSame('de', $result['language_key']); + } + + public static function invalidPerformParametersProvider(): array + { + return [ + 'non-array parameters' => ['not-an-array'], + 'empty array' => [[]], + 'missing language_key' => [['enabled' => true]], + 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], + 'empty string language_key' => [['language_key' => '', 'enabled' => true]], + 'whitespace-only language_key' => [['language_key' => ' ', 'enabled' => true]], + 'missing enabled' => [['language_key' => 'de']], + 'non-bool enabled: string "1"' => [['language_key' => 'de', 'enabled' => '1']], + 'non-bool enabled: string "true"' => [['language_key' => 'de', 'enabled' => 'true']], + 'non-bool enabled: string ""' => [['language_key' => 'de', 'enabled' => '']], + 'non-bool enabled: int 1' => [['language_key' => 'de', 'enabled' => 1]], + 'non-bool enabled: int 0' => [['language_key' => 'de', 'enabled' => 0]], + 'non-bool enabled: float 1.0' => [['language_key' => 'de', 'enabled' => 1.0]], + 'non-bool enabled: null' => [['language_key' => 'de', 'enabled' => null]], + 'non-bool enabled: array' => [['language_key' => 'de', 'enabled' => [true]]], + ]; + } + + #[DataProvider('invalidPerformParametersProvider')] + public function testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings(mixed $parameters): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(repository: $repository)->perform($parameters); + } + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\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( + ['language_key' => 'de', 'enabled' => true, 'changed' => true], + $result->value() + ); + } + + /** A denied "write" yields a Result\Error and leaves the Setting untouched. */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouchesSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $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' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\DomainException::class, $result->error()); + $this->assertSame(403, $result->error()->getCode()); + } + + /** A Throwable from perform() becomes a Result\Error. */ + public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->method('isPageTranslationEnabled')->willReturn(false); + $repository->method('setPageTranslationEnabled')->willThrowException(new \RuntimeException('database write failed')); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertSame(500, $error->getCode()); + $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); + } + + /** A missing 'language_key' fails the required Text field in grind(), before rbac is touched. */ + public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $result = $this->createActivity(rbac_system: $rbac)->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array + { + return [ + 'missing language_key' => [['enabled' => true]], + ]; + } + + #[DataProvider('invalidRawParametersRejectedByGrindWithStringErrorProvider')] + public function testMaybePerformAsRejectsRawParametersAtGrindLevelWithStringErrorWithoutTouchingSettingsOrRbac( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $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, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersProvider(): array + { + return [ + // a non-string 'language_key' fails the Text field's isClientSideValueOk() inside grind() + 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], + // outside the whitelist of GrindsFormInput::normalizeCheckboxRawValue() + 'non-bool enabled: array' => [['language_key' => 'de', 'enabled' => [true]]], + 'non-bool enabled: float 1.0' => [['language_key' => 'de', 'enabled' => 1.0]], + ]; + } + + #[DataProvider('invalidRawParametersProvider')] + public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithoutTouchingSettingsOrRbac( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $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, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de']); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => false, 'changed' => true], + $result->value() + ); + } + + /** A whitespace-only language_key passes grind()'s required-check, so perform() rejects it after the permission check. */ + public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermissionCheck(): 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' => ' ', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + } + + public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'int 1' => [1], + ]; + } + + public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array + { + return [ + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 0' => [0], + 'null' => [null], + ]; + } + + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\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' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => true, 'changed' => true], + $result->value() + ); + } + + /** Same for "false". */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); + + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => false, 'changed' => true], + $result->value() + ); + } + + /** An unknown language_key is rejected by perform() itself, for direct callers too. */ + public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(repository: $repository) + ->perform(['language_key' => 'xx', 'enabled' => true]); + } + + /** The message embeds the key raw: escaping is RendersActivityErrors' job. */ + public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescapedKey(): void + { + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); + + $dangerous_key = '&"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->createMock(\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 $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/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php new file mode 100644 index 000000000000..0b1a0c74b048 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -0,0 +1,597 @@ +buildFakeLanguageWorld(['de' => []]); + + return $this->createActivity($lng_objects, $obj_language_factory); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePerformAs(): void + { + $rbac = $this->createMock(\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()); + } + + public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void + { + $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'); + }; + + $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 ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ + 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 ambiguity surfaces as Result\Error and the object factory is never reached. */ + public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void + { + $rbac = $this->createMock(\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 does not poison unrelated, unambiguous ones. */ + 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 covers all requested keys before any uninstall: 'de' must stay installed when 'fr' is ambiguous. */ + 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' first on purpose + $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, the check ran before the per-key loop + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->uninstallCallCount()); + } + } + + /** Same, end-to-end through maybePerformAs(). */ + public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void + { + $rbac = $this->createMock(\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()); + } + + /** + * @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', + ); + $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(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 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->createMock(\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->createMock(\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()); + } + + private function createActivity( + \Closure $lng_objects, + \Closure $obj_language_factory, + ?\ilRbacSystem $rbac = null, + ): UninstallLanguage { + return new UninstallLanguage( + $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), + $this->languageFolder(), + $this->languageObjectProvider($lng_objects, $obj_language_factory) + ); + } +} + +/** + * Stand-in for \ilObjLanguage (see above); counts uninstall() calls. + */ +final class FakeLanguageObject +{ + private int $uninstall_calls = 0; + + /** + * @param string $uninstall_return_value '' mimics uninstall() rejecting the call despite the outer guards + */ + 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', + ) { + } + + 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; + } + + public function uninstallCallCount(): int + { + return $this->uninstall_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php new file mode 100644 index 000000000000..53f42f62fd8b --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -0,0 +1,365 @@ +createSetupLanguageMock([], [], ['de']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + + return $this->createActivity($setup_language); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + 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(\RuntimeException::class); + $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(\RuntimeException::class); + $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->createSetupLanguageMock([], [], []))->perform($parameters); + } + + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->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->createSetupLanguageMock([], [], []) + ); + + $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->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], ['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->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], ['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, + ): UpdateLanguage { + return new UpdateLanguage( + $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), + $setup_language, + $this->languageFolder() + ); + } + + 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; + } +} diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php new file mode 100644 index 000000000000..74162e12c26b --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php @@ -0,0 +1,87 @@ +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()); + } +} diff --git a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php new file mode 100644 index 000000000000..09e40e3c49ad --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php @@ -0,0 +1,56 @@ + */ + private function names(InType $type): array + { + $result = (new Reader(new EvalLightMockBuilder()))->read(new ILIAS\Language()); + $names = []; + foreach ($result->getInDependenciesOf($type) as $dependency) { + $names[] = $dependency->getName(); + } + + return $names; + } + + public function testDatabaseConnectionIsPulledNotUsed(): void + { + self::assertContains(Connection::class, $this->names(InType::PULL)); + self::assertNotContains(Connection::class, $this->names(InType::USE)); + } + + public function testRbacIsUsed(): void + { + self::assertContains(RBAC::class, $this->names(InType::USE)); + } +} diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php new file mode 100644 index 000000000000..f5341de7af94 --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -0,0 +1,399 @@ +|\ArrayAccess|null $use + * @return array{0: array, 1: RenamingDIC, 2: array, 3: RenamingDIC, 4: array, 5: PimpleContainer, 6: array, 7: PimpleContainer} + */ + private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\Database\Connection $connection = null): array + { + $define = []; + $use ??= [ + \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), + \ILIAS\AccessControl\PublicInterface\RBAC::class => $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + ]; + $use[\ILIAS\Environment\Configuration\Installation\Directories::class] ??= $this->createDirectories( + (string) realpath(__DIR__ . '/../../../../') + ); + $seek = [ + LanguageFileDirectory::class => [], + ]; + $pull = [ + RefineryFactory::class => $this->createStub(RefineryFactory::class), + \ILIAS\Database\Connection::class => $connection ?? $this->createStub(\ILIAS\Database\Connection::class), + ]; + + // RenamingDIC (as in the generated bootstrap) keeps several assignments to one key apart; a plain Pimple\Container overwrites + $implement = new RenamingDIC(new PimpleContainer()); + $this->contributions = new PimpleContainer(); + $contribute = new RenamingDIC($this->contributions); + $provide = new PimpleContainer(); + $internal = new PimpleContainer(); + + (new LanguageComponent())->init( + $define, + $implement, + $use, + $contribute, + $seek, + $provide, + $pull, + $internal + ); + + return [$define, $implement, $use, $contribute, $seek, $provide, $pull, $internal]; + } + + /** + * All instances contributed for $type, independent of the position of the contribution in init(). + * + * @return list + */ + private function contributionsOf(string $type): array + { + $contributions = []; + foreach ($this->contributions->keys() as $key) { + if (preg_match('/^' . preg_quote($type, '/') . '_\\d+$/', $key) === 1) { + $contributions[] = $this->contributions[$key]; + } + } + + return $contributions; + } + + private function contributedSetupAgent(): \ilLanguageSetupAgent + { + $agents = $this->contributionsOf(\ILIAS\Setup\Agent::class); + self::assertCount(1, $agents); + self::assertInstanceOf(\ilLanguageSetupAgent::class, $agents[0]); + + return $agents[0]; + } + + public function testDefinesTheLanguageInterface(): void + { + [$define] = $this->initComponent(); + + self::assertSame([\ILIAS\Language\Language::class], $define); + } + + public function testProvideEntriesResolveToExpectedConcreteClasses(): void + { + [, , , , , $provide] = $this->initComponent(); + + self::assertInstanceOf(LanguageFileDirectoryManager::class, $provide[LanguageFileDirectoryManager::class]); + self::assertFalse(isset($provide[InstalledLanguageRepository::class])); + self::assertFalse(isset($provide[LanguageInstallationManager::class])); + self::assertInstanceOf(InstallLanguage::class, $provide[InstallLanguage::class]); + self::assertInstanceOf(UpdateLanguage::class, $provide[UpdateLanguage::class]); + self::assertInstanceOf(UninstallLanguage::class, $provide[UninstallLanguage::class]); + self::assertInstanceOf(RemoveLocalLanguageChanges::class, $provide[RemoveLocalLanguageChanges::class]); + self::assertInstanceOf(AddLanguageEntry::class, $provide[AddLanguageEntry::class]); + self::assertInstanceOf(SetLanguageDetectionEnabled::class, $provide[SetLanguageDetectionEnabled::class]); + self::assertInstanceOf(SetLanguageTranslationEnabled::class, $provide[SetLanguageTranslationEnabled::class]); + } + + public function testProvidedServicesShareTheSameInternalSingletonInstances(): void + { + [, , , , , $provide, , $internal] = $this->initComponent(); + + // $provide[...] re-exports $internal[...]: both must be the same object + self::assertSame($internal[LanguageFileDirectoryManager::class], $provide[LanguageFileDirectoryManager::class]); + self::assertSame($internal[InstallLanguage::class], $provide[InstallLanguage::class]); + self::assertSame($internal[UpdateLanguage::class], $provide[UpdateLanguage::class]); + self::assertSame($internal[UninstallLanguage::class], $provide[UninstallLanguage::class]); + self::assertSame($internal[RemoveLocalLanguageChanges::class], $provide[RemoveLocalLanguageChanges::class]); + self::assertSame($internal[AddLanguageEntry::class], $provide[AddLanguageEntry::class]); + self::assertSame( + $internal[SetLanguageDetectionEnabled::class], + $provide[SetLanguageDetectionEnabled::class] + ); + self::assertSame( + $internal[SetLanguageTranslationEnabled::class], + $provide[SetLanguageTranslationEnabled::class] + ); + } + + public function testContributeEntriesResolveToExpectedConcreteClasses(): void + { + $this->initComponent(); + + $directories = $this->contributionsOf(LanguageFileDirectory::class); + self::assertCount(1, $directories); + self::assertInstanceOf(MainLanguageFileDirectory::class, $directories[0]); + + $this->contributedSetupAgent(); + + $activities = array_map( + static fn(object $activity): string => $activity::class, + $this->contributionsOf(\ILIAS\Component\Activities\Activity::class) + ); + sort($activities); + $expected = [ + InstallLanguage::class, + UpdateLanguage::class, + UninstallLanguage::class, + RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, + SetLanguageDetectionEnabled::class, + SetLanguageTranslationEnabled::class, + ]; + sort($expected); + self::assertSame($expected, $activities); + + $user_settings = $this->contributionsOf(\ILIAS\User\Settings\UserSettings::class); + self::assertCount(1, $user_settings); + self::assertInstanceOf(UserSettingsSettings::class, $user_settings[0]); + } + + public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstances(): void + { + [, , , , , $provide] = $this->initComponent(); + + // the Setup path and the GUI must share the very same Activity instances, not merely equal ones + $contributed = $this->contributionsOf(\ILIAS\Component\Activities\Activity::class); + foreach ([ + InstallLanguage::class, UpdateLanguage::class, UninstallLanguage::class, RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, SetLanguageDetectionEnabled::class, SetLanguageTranslationEnabled::class, + ] as $class) { + self::assertTrue(in_array($provide[$class], $contributed, true), $class . ' is not contributed as provided'); + } + } + + public function testImplementCandidatesAreOfferedInDeclarationOrder(): void + { + [, $implement] = $this->initComponent(); + + // consumers disambiguate by position (Renderer::renderUse()): the Setup candidate must be registered before the runtime one + self::assertInstanceOf(\ilSetupLanguage::class, $implement[\ILIAS\Language\Language::class . '_0']); + self::assertInstanceOf( + LanguageLegacyInitialisationAdapter::class, + $implement[\ILIAS\Language\Language::class . '_1'] + ); + } + + private function createDirectories(string $root): \ILIAS\Environment\Configuration\Installation\Directories + { + $directories = $this->createStub(\ILIAS\Environment\Configuration\Installation\Directories::class); + $directories->method('getRoot')->willReturn($root); + + return $directories; + } + + public function testRepositoryGetsTheRootFromDirectories(): void + { + [, , , , , , , $internal] = $this->initComponent([ + \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), + \ILIAS\AccessControl\PublicInterface\RBAC::class => $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + \ILIAS\Environment\Configuration\Installation\Directories::class => $this->createDirectories('/ilias-root'), + ]); + + self::assertSame( + '/ilias-root', + self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'installation_root')->getPath() + ); + } + + public function testRepositoryDoesNotReadTheRootWhileWiring(): void + { + $directories = $this->createMock(\ILIAS\Environment\Configuration\Installation\Directories::class); + $directories->expects(self::never())->method('getRoot'); + + [, , , , , , , $internal] = $this->initComponent([ + \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), + \ILIAS\AccessControl\PublicInterface\RBAC::class => $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + \ILIAS\Environment\Configuration\Installation\Directories::class => $directories, + ]); + + self::assertInstanceOf(InstalledLanguageDatabaseRepository::class, $internal[InstalledLanguageDatabaseRepository::class]); + } + + private function resolveEverything(PimpleContainer $provide): void + { + foreach ([ + InstallLanguage::class, UpdateLanguage::class, UninstallLanguage::class, RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, SetLanguageDetectionEnabled::class, SetLanguageTranslationEnabled::class, + ] as $key) { + self::assertIsObject($provide[$key]); + } + $this->contributedSetupAgent(); + } + + /** + * @template T of \ilDBInterface + * @param class-string $class + * @return T&\PHPUnit\Framework\MockObject\MockObject + */ + private function databaseExpectingQueries(string $class, int $queries): \ilDBInterface + { + $db = $this->createMock($class); + $db->expects(self::exactly($queries))->method('query'); + + return $db; + } + + /** Repository and RepositoryProvider query the pulled connection, never $GLOBALS['ilDB']. */ + public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void + { + $dic = new \ILIAS\DI\Container(); + $GLOBALS['DIC'] = $dic; + $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 0); + $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 2); + + [, , , , , $provide, , $internal] = $this->initComponent(null, $connection); + $this->resolveEverything($provide); + + self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); + // the module cache update of the provider shared by the runtime Activities; only its lookup runs without a legacy container + $internal[RepositoryProvider::class]->updateModuleCache('de', 'common', 'x', 'y'); + self::assertSame($dic, $GLOBALS['DIC']); + } + + public function testWiringNeverCallsRbacSystemWhileResolving(): void + { + $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class); + $rbac->expects(self::never())->method('system'); + + [, , , , , $provide] = $this->initComponent([ + \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), + \ILIAS\AccessControl\PublicInterface\RBAC::class => $rbac, + ]); + + $this->resolveEverything($provide); + } + + public function testSetupAgentIsBuildableWithoutAnyRbacEntry(): void + { + $use = new class () extends \ArrayObject { + public function offsetGet(mixed $key): mixed + { + if ($key === \ILIAS\AccessControl\PublicInterface\RBAC::class) { + throw new \LogicException('the Setup agent must not touch RBAC'); + } + return parent::offsetGet($key); + } + }; + $use[\ILIAS\Language\Language::class] = $this->createStub(\ILIAS\Language\Language::class); + + $this->initComponent($use); + + $this->contributedSetupAgent(); + } + + /** @return array}> */ + public static function setupLanguageActivities(): array + { + return [ + 'install' => [InstallLanguage::class], + 'update' => [UpdateLanguage::class], + ]; + } + + private function performReadOnly(InstallLanguage|UpdateLanguage $activity): void + { + $activity->perform( + $activity instanceof InstallLanguage + ? ['language_keys' => ['fr'], 'mode' => InstallLanguage::MODE_INSTALL_LOCAL] + : ['language_keys' => ['fr']] + ); + } + + /** @param class-string $class */ + #[DataProvider('setupLanguageActivities')] + public function testWebActivitiesReadTheInstalledLanguagesFromThePulledConnection(string $class): void + { + $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 0); + $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 1); + + [, , , , , $provide] = $this->initComponent(null, $connection); + + $this->performReadOnly($provide[$class]); + } + + public function testSetupLanguageInstanceReadsTheGlobalDatabase(): void + { + $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 1); + $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 0); + + [, $implement, , , , , , $internal] = $this->initComponent(null, $connection); + + self::assertSame($internal[\ilSetupLanguage::class], $implement[\ILIAS\Language\Language::class . '_0']); + self::assertSame([], $internal[\ilSetupLanguage::class]->getInstalledLanguages()); + } + + /** + * The agent's activities are only reachable through protected properties. + * + * @param class-string $class + */ + #[DataProvider('setupLanguageActivities')] + public function testSetupAgentActivitiesDoNotUseThePulledConnection(string $class): void + { + $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 1); + $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 0); + + $this->initComponent(null, $connection); + $agent = $this->contributedSetupAgent(); + + $this->performReadOnly( + self::readPrivateProperty($agent, $class === InstallLanguage::class ? 'install_language' : 'update_language') + ); + } + + private static function readPrivateProperty(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } +} diff --git a/components/ILIAS/Language/tests/LanguageFolderLegacyProxyTest.php b/components/ILIAS/Language/tests/LanguageFolderLegacyProxyTest.php new file mode 100644 index 000000000000..724d6f01b9b3 --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageFolderLegacyProxyTest.php @@ -0,0 +1,43 @@ +createStub(ilDBInterface::class); + $db->method('quote')->willReturn("'lngf'"); + $db->method('query')->willReturnCallback(function () use (&$queried): ilDBStatement { + $queried = true; + return $this->createStub(ilDBStatement::class); + }); + $db->method('fetchAssoc')->willReturn(['ref_id' => '42']); + $GLOBALS['DIC']['ilDB'] = $db; + + $proxy = new LanguageFolderLegacyProxy(); + self::assertFalse($queried); + + self::assertSame(42, $proxy->getRefId()); + self::assertTrue($queried); + } +} diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php new file mode 100644 index 000000000000..854f062b5c9a --- /dev/null +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -0,0 +1,267 @@ +createMock(\ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + + return $lng; + } + + public function testCode403ShowsTheNoPermissionMessageAndIsNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage(new \DomainException('internal wording ', 403)); + + $this->assertSame('msg_no_perm_write', $message); + } + + /** @return array>|list> */ + public static function clientErrorProvider(): array + { + return [ + 'invalid input' => [ + new InvalidInputException('language_keys: not_min_length'), + 'language_keys: not_min_length', + ], + 'ambiguous title embeds the raw title' => [ + new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'), + 'Multiple language objects share the title(s) "fr".', + ], + 'dangerous markup' => [ + new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'), + 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', + ], + 'any other 4xx (404)' => [new \RuntimeException('gone', 404), '<i>gone</i>'], + ]; + } + + /** Every 4xx other than 403 is shown, HTML-escaped (setOnScreenMessage() renders unescaped), and never logged. */ + #[DataProvider('clientErrorProvider')] + public function testA4xxErrorMessageIsHtmlEscapedAndNeverLogged(\Exception $error, string $expected): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame($expected, $message); + } + + /** Without ENT_SUBSTITUTE invalid UTF-8 made htmlspecialchars() return an empty string for the whole message. */ + public function testA4xxErrorMessageWithInvalidUtf8IsNotSilentlyDroppedToAnEmptyString(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new InvalidInputException("Invalid language key \"d\xC3\x28\": bad input."); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertNotSame('', $message); + $this->assertStringStartsWith('Invalid language key "d', $message); + $this->assertStringContainsString("\u{FFFD}", $message); + $this->assertStringEndsWith('": bad input.', $message); + } + + private static function pdoExceptionWithSqlState(string $sql_state, string $message): \PDOException + { + return new class ($message, $sql_state) extends \PDOException { + public function __construct(string $message, string $sql_state) + { + parent::__construct($message); + $this->code = $sql_state; + } + }; + } + + /** @return array>|list> */ + public static function nonClientErrorProvider(): array + { + return [ + 'code 0' => [new \RuntimeException('boom')], + 'code 399' => [new \RuntimeException('boom', 399)], + 'code 500' => [new \RuntimeException('boom', 500)], + 'PDOException, SQLSTATE 42S02 as string code' => [self::pdoExceptionWithSqlState('42S02', 'boom')], + 'PDOException, SQLSTATE 42S22 as string code' => [self::pdoExceptionWithSqlState('42S22', 'boom')], + ]; + } + + /** Anything but 4xx is never shown: its message is logged, a generic 'action_aborted' returned. */ + #[DataProvider('nonClientErrorProvider')] + public function testANon4xxThrowableIsLoggedAndAGenericMessageIsReturned(\Throwable $error): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->once())->method('error')->with($this->stringContains('boom')); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame('action_aborted', $message); + } + + /** The masked 500 of Failure::of() logs the original behind it and shows only the generic text. */ + public function testAMasked500LogsTheOriginalCauseAndShowsOnlyTheGenericMessage(): void + { + $logged = ''; + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->once())->method('error')->willReturnCallback( + static function (string $message) use (&$logged): void { + $logged = $message; + } + ); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage(Failure::of(new \PDOException('SQL: secret table'))); + + $this->assertSame('action_aborted', $message); + $this->assertStringContainsString('The activity could not be performed.', $logged); + $this->assertStringContainsString('PDOException: SQL: secret table', $logged); + } + + /** @return array>|list> */ + public static function inputErrorCodeProvider(): array + { + return [[399, false], [400, true], [403, false], [404, true], [409, true], [499, true], [500, false]]; + } + + public function testIsActivityInputErrorIsFalseForAStringSqlStateCode(): void + { + $host = $this->host($this->createMock(\ilLogger::class), $this->languageMockReturningTopicAsIs()); + + $this->assertFalse($host->callIsActivityInputError(self::pdoExceptionWithSqlState('42S02', 'x'))); + } + + #[DataProvider('inputErrorCodeProvider')] + public function testIsActivityInputErrorIsTrueOnlyFor4xxExcept403(int $code, bool $expected): void + { + $host = $this->host($this->createMock(\ilLogger::class), $this->languageMockReturningTopicAsIs()); + + $this->assertSame($expected, $host->callIsActivityInputError(new \RuntimeException('x', $code))); + } + + /** A wrapper exception must not hide the root cause: the full getPrevious() chain is logged. */ + public function testANon4xxThrowableWithAPreviousExceptionLogsTheFullCauseChain(): void + { + $root_cause = new \PDOException('duplicate key value violates unique constraint'); + $wrapper = new \RuntimeException('failed to write language entry', 0, $root_cause); + + $logged = []; + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->once())->method('error')->willReturnCallback( + static function (string $message) use (&$logged): void { + $logged[] = $message; + } + ); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($wrapper); + + $this->assertSame('action_aborted', $message); + $this->assertCount(1, $logged); + $logged_message = $logged[0]; + + $this->assertStringContainsString(\RuntimeException::class . ': failed to write language entry', $logged_message); + $this->assertStringContainsString(\PDOException::class . ': duplicate key value violates unique constraint', $logged_message); + + $this->assertStringContainsString("Caused by:\n", $logged_message); + $this->assertLessThan( + strpos($logged_message, \PDOException::class . ': duplicate key value violates unique constraint'), + strpos($logged_message, \RuntimeException::class . ': failed to write language entry') + ); + } + + /** A three-deep chain logs every link. */ + public function testAThreeDeepCauseChainLogsEveryLink(): void + { + $innermost = new \InvalidArgumentException('column "xx" does not exist'); + $middle = new \PDOException('query failed', 0, $innermost); + $outer = new \RuntimeException('database operation failed', 0, $middle); + + $logged = []; + $logger = $this->createMock(\ilLogger::class); + $logger->method('error')->willReturnCallback( + static function (string $message) use (&$logged): void { + $logged[] = $message; + } + ); + + $this->host($logger, $this->languageMockReturningTopicAsIs())->callActivityErrorMessage($outer); + + $this->assertCount(1, $logged); + $logged_message = $logged[0]; + + $this->assertStringContainsString(\RuntimeException::class . ': database operation failed', $logged_message); + $this->assertStringContainsString(\PDOException::class . ': query failed', $logged_message); + $this->assertStringContainsString( + \InvalidArgumentException::class . ': column "xx" does not exist', + $logged_message + ); + } +} + +/** + * Minimal host: mixes in the trait, sets its two collaborators in the constructor and exposes activityErrorMessage(). + */ +final class RendersActivityErrorsTestHost +{ + use RendersActivityErrors; + + public function __construct( + \ilLogger $activity_error_logger, + private readonly \ilLanguage $lng, + ) { + $this->activity_error_logger = $activity_error_logger; + } + + public function callActivityErrorMessage(\Throwable $error): string + { + return $this->activityErrorMessage($error); + } + + public function callIsActivityInputError(\Throwable $error): bool + { + return $this->isActivityInputError($error); + } +} diff --git a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php new file mode 100644 index 000000000000..0685d89609b9 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php @@ -0,0 +1,479 @@ +createMock(ilDBInterface::class); + $db->method('quote')->willReturnCallback( + static fn(mixed $value, string $type): string => "'" . (string) $value . "'" + ); + $db->method('like')->willReturnCallback( + static fn(string $column, string $type, string $value = "?", bool $ci = true): string => + $column . " LIKE " . "'" . $value . "'" + ); + + return $db; + } + + /** + * The read methods call fetchObject() on the database object, not on the statement, so the rows are stubbed on + * $db. query() is left to each test, so no two stubs compete. + * + * @param list> $rows each row as an associative array of column => value + */ + private function configureDbToReturnObjectRows(MockObject&ilDBInterface $db, array $rows): void + { + $objects = array_map(static fn(array $row): \stdClass => (object) $row, $rows); + $objects[] = null; // terminates the while ($row = ...) loop + $db->method('fetchObject')->willReturn(...$objects); + } + + /** + * @param list> $rows each row as an associative array of column => value + */ + private function createStatementReturningAssocRows(array $rows): MockObject&ilDBStatement + { + $statement = $this->createMock(ilDBStatement::class); + $rows[] = null; // terminates the while ($row = ...) loop + $statement->method('fetchRow')->willReturn(...$rows); + + return $statement; + } + + private function createRepository( + ilDBInterface $db, + string $absolute_path = '/does/not/matter/for/db-only/tests' + ): InstalledLanguageDatabaseRepository { + return new InstalledLanguageDatabaseRepository( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + new FixedInstallationRoot($absolute_path) + ); + } + + public function testGetAvailableLanguagesMapsObjIdAndStatusToTheCorrectLanguageNotSwapped(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createMock(ilDBStatement::class)); + $this->configureDbToReturnObjectRows($db, [ + ['title' => 'de', 'description' => 'installed', 'obj_id' => 5], + ['title' => 'en', 'description' => 'not_installed', 'obj_id' => 8], + ]); + + $result = $this->createRepository($db)->getAvailableLanguages(); + + $this->assertSame( + [ + 'de' => ['obj_id' => 5, 'status' => 'installed'], + 'en' => ['obj_id' => 8, 'status' => 'not_installed'], + ], + $result + ); + } + + public function testGetAvailableLanguagesReturnsEmptyArrayWhenNoLanguagesAreKnown(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createMock(ilDBStatement::class)); + $this->configureDbToReturnObjectRows($db, []); + + $this->assertSame([], $this->createRepository($db)->getAvailableLanguages()); + } + + public function testGetInstalledLanguagesQueriesForInstalledPrefixAndReturnsTitles(): void + { + $db = $this->createReadDatabaseMock(); + $captured_query = null; + $db->method('query')->willReturnCallback( + function (string $query) use (&$captured_query): ilDBStatement { + $captured_query = $query; + return $this->createMock(ilDBStatement::class); + } + ); + $this->configureDbToReturnObjectRows($db, [ + ['title' => 'de'], + ['title' => 'en'], + ]); + + $result = $this->createRepository($db)->getInstalledLanguages(); + + $this->assertSame(['de', 'en'], $result); + $this->assertStringContainsString("type = 'lng'", $captured_query); + $this->assertStringContainsString("description LIKE 'installed%'", $captured_query); + } + + public function testGetInstalledLocalLanguagesQueriesForExactlyInstalledLocalAndReturnsTitles(): void + { + $db = $this->createReadDatabaseMock(); + $captured_query = null; + $db->method('query')->willReturnCallback( + function (string $query) use (&$captured_query): ilDBStatement { + $captured_query = $query; + return $this->createMock(ilDBStatement::class); + } + ); + $this->configureDbToReturnObjectRows($db, [ + ['title' => 'de'], + ]); + + $result = $this->createRepository($db)->getInstalledLocalLanguages(); + + $this->assertSame(['de'], $result); + $this->assertStringContainsString("description = 'installed_local'", $captured_query); + } + + public function testGetLocalChangesDefaultsMinAndMaxDateWhenEmptyStringsArePassed(): void + { + $db = $this->createReadDatabaseMock(); + $captured_query = null; + $db->method('query')->willReturnCallback( + function (string $query) use (&$captured_query): ilDBStatement { + $captured_query = $query; + return $this->createStatementReturningAssocRows([]); + } + ); + + $this->createRepository($db)->getLocalChanges('de', '', ''); + + $this->assertStringContainsString("'1980-01-01 00:00:00'", $captured_query); + $this->assertStringContainsString("'2200-01-01 00:00:00'", $captured_query); + } + + public function testGetLocalChangesKeepsExplicitlyGivenDatesInsteadOfDefaulting(): void + { + $db = $this->createReadDatabaseMock(); + $captured_query = null; + $db->method('query')->willReturnCallback( + function (string $query) use (&$captured_query): ilDBStatement { + $captured_query = $query; + return $this->createStatementReturningAssocRows([]); + } + ); + + $this->createRepository($db)->getLocalChanges('de', '2020-01-01 00:00:00', '2021-01-01 00:00:00'); + + $this->assertStringContainsString("'2020-01-01 00:00:00'", $captured_query); + $this->assertStringContainsString("'2021-01-01 00:00:00'", $captured_query); + $this->assertStringNotContainsString('1980-01-01', $captured_query); + $this->assertStringNotContainsString('2200-01-01', $captured_query); + } + + public function testGetLocalChangesStructuresResultAsModuleThenIdentifierThenValue(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createStatementReturningAssocRows([ + ['module' => 'common', 'identifier' => 'foo', 'value' => 'Foo Value'], + ['module' => 'common', 'identifier' => 'bar', 'value' => 'Bar Value'], + ['module' => 'other', 'identifier' => 'baz', 'value' => 'Baz Value'], + ])); + + $result = $this->createRepository($db)->getLocalChanges('de'); + + $this->assertSame( + [ + 'common' => ['foo' => 'Foo Value', 'bar' => 'Bar Value'], + 'other' => ['baz' => 'Baz Value'], + ], + $result + ); + } + + public function testGetLocalChangesReturnsEmptyArrayWhenNothingMatches(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createStatementReturningAssocRows([])); + + $this->assertSame([], $this->createRepository($db)->getLocalChanges('de')); + } + + public function testGetLanguageEntriesStructuresResultAsModuleThenIdentifierThenValue(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createStatementReturningAssocRows([ + ['module' => 'common', 'identifier' => 'foo', 'value' => 'Foo Value'], + ['module' => 'common', 'identifier' => 'bar', 'value' => 'Bar Value'], + ['module' => 'other', 'identifier' => 'baz', 'value' => 'Baz Value'], + ])); + + $result = $this->createRepository($db)->getLanguageEntries('de'); + + $this->assertSame( + [ + 'common' => ['foo' => 'Foo Value', 'bar' => 'Bar Value'], + 'other' => ['baz' => 'Baz Value'], + ], + $result + ); + } + + public function testGetLanguageEntriesReturnsEmptyArrayWhenNothingMatches(): void + { + $db = $this->createReadDatabaseMock(); + $db->method('query')->willReturn($this->createStatementReturningAssocRows([])); + + $this->assertSame([], $this->createRepository($db)->getLanguageEntries('de')); + } + + /** + * Unlike getLocalChanges(), getLanguageEntries() returns every stored entry of the language key, so its query + * must not filter on local_change. + */ + public function testGetLanguageEntriesQueryDoesNotFilterByLocalChange(): void + { + $db = $this->createReadDatabaseMock(); + $captured_query = null; + $db->method('query')->willReturnCallback( + function (string $query) use (&$captured_query): ilDBStatement { + $captured_query = $query; + return $this->createStatementReturningAssocRows([]); + } + ); + + $this->createRepository($db)->getLanguageEntries('de'); + + $this->assertStringContainsString("lang_key = 'de'", $captured_query); + $this->assertStringNotContainsString('local_change', $captured_query); + } + + public function testGetLocalLanguagesExtractsKeysFromLocalFilesAndIgnoresUnrelatedFiles(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/customizing/ilias_de.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/ilias_en.lang.local', 'irrelevant content'); + // wrong naming scheme (missing .local suffix, a typo): ignored + file_put_contents($root . '/lang/customizing/ilias_fr.lang', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/README.md', 'irrelevant content'); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $result = $repository->getLocalLanguages(); + + sort($result); + $this->assertSame(['de', 'en'], $result); + $this->assertSame(array_values(array_unique($result)), array_values($result)); + } finally { + $this->removeDirectory($root); + } + } + + /** + * The discovery regex must accept exactly ParsesLanguageKeyList::LANGUAGE_KEY_FORMAT ('/^[a-z]{2}$/'): other + * file names must be ignored, or they would fail later in the Activity's stricter validation. + */ + public function testGetLocalLanguagesIgnoresFilesWithAnInvalidLanguageKeyShapeInTheirName(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/customizing/ilias_de.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/ilias_DE.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/ilias_12.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/ilias_Fr.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/customizing/ilias_deu.lang.local', 'irrelevant content'); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $result = $repository->getLocalLanguages(); + + $this->assertSame(['de'], $result); + } finally { + $this->removeDirectory($root); + } + } + + /** + * Same for getInstallableLanguages() (lang/) instead of getLocalLanguages() (lang/customizing/). + */ + public function testGetInstallableLanguagesIgnoresFilesWithAnInvalidLanguageKeyShapeInTheirName(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', 'irrelevant content'); + file_put_contents($root . '/lang/ilias_DE.lang', 'irrelevant content'); + file_put_contents($root . '/lang/ilias_12.lang', 'irrelevant content'); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $result = $repository->getInstallableLanguages(); + + $this->assertSame(['de'], $result); + } finally { + $this->removeDirectory($root); + } + } + + public function testGetLocalLanguagesReturnsEmptyArrayWhenCustomizingDirectoryIsEmpty(): void + { + $root = $this->createTempInstallationRoot(); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + $this->assertSame([], $repository->getLocalLanguages()); + } finally { + $this->removeDirectory($root); + } + } + + public function testGetInstallableLanguagesExtractsKeysFromMainFilesAndIgnoresUnrelatedFiles(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', 'irrelevant content'); + file_put_contents($root . '/lang/ilias_en.lang', 'irrelevant content'); + // a local file is not installable, nor is a name outside the "ilias_xx.lang" scheme + file_put_contents($root . '/lang/ilias_fr.lang.local', 'irrelevant content'); + file_put_contents($root . '/lang/notes.txt', 'irrelevant content'); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $result = $repository->getInstallableLanguages(); + + sort($result); + $this->assertSame(['de', 'en'], $result); + } finally { + $this->removeDirectory($root); + } + } + + public function testCheckLocalLanguageFileReturnsFalseWhenNoLocalFileExists(): void + { + $root = $this->createTempInstallationRoot(); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + // unlike checkLanguage(), a missing local file is not acceptable here + $this->assertFalse($repository->checkLocalLanguageFile('de')); + } finally { + $this->removeDirectory($root); + } + } + + public function testCheckLocalLanguageFileReturnsTrueForAValidLocalFile(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents( + $root . '/lang/customizing/ilias_de.lang.local', + "\ncommon#:#test#:#Custom Value\n" + ); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + $this->assertTrue($repository->checkLocalLanguageFile('de')); + } finally { + $this->removeDirectory($root); + } + } + + public function testCheckLocalLanguageFileReturnsFalseForAnInvalidLocalFile(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents( + $root . '/lang/customizing/ilias_de.lang.local', + "common#:#test#:#Custom Value\n" + ); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + $this->assertFalse($repository->checkLocalLanguageFile('de')); + } finally { + $this->removeDirectory($root); + } + } + + public function testCheckLocalLanguageFileReturnsFalseForMalformedLine(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents( + $root . '/lang/customizing/ilias_de.lang.local', + "\nonly_one_field\n" + ); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + $this->assertFalse($repository->checkLocalLanguageFile('de')); + } finally { + $this->removeDirectory($root); + } + } + + /** + * getCustomizingDirectories() always yields one directory in production, so the "zero directories" branch + * (`return false`) is reachable only with a mocked manager, which this class otherwise avoids. + */ + public function testCheckLocalLanguageFileReturnsFalseWhenNoCustomizingDirectoryIsConfiguredAtAll(): void + { + $manager = $this->createMock(LanguageFileDirectoryManager::class); + $manager->method('getCustomizingDirectories')->willReturnCallback( + static function (): \Generator { + return; + yield; // unreachable; makes this a generator that yields nothing + } + ); + + $repository = new InstalledLanguageDatabaseRepository( + $this->createReadDatabaseMock(), + $manager, + new FixedInstallationRoot('/does/not/matter/for/this/test') + ); + + $this->assertFalse($repository->checkLocalLanguageFile('de')); + } + + private function createTempInstallationRoot(): string + { + $dir = sys_get_temp_dir() . '/ilias_lang_repo_test_' . bin2hex(random_bytes(8)); + mkdir($dir . '/lang/customizing', 0777, true); + return $dir; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php new file mode 100644 index 000000000000..830e5342bdc5 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -0,0 +1,948 @@ +now() instead, which is asserted explicitly as well. The clock exists + * for testability, not because the date was ever computed incorrectly. + */ +class LanguageInstallationManagerTest extends TestCase +{ + private function createDatabaseMock(): MockObject&ilDBInterface + { + $db = $this->createMock(ilDBInterface::class); + $db->method('nextId')->willReturn(42); + $db->method('quote')->willReturnCallback( + static fn(mixed $value): string => "'" . (string) $value . "'" + ); + $db->method('now')->willReturn('NOW()'); + $db->method('manipulate')->willReturn(1); + + return $db; + } + + private function createManager( + ilDBInterface $db, + InstalledLanguageRepository $repository, + ?\DateTimeImmutable $now = null + ): LanguageInstallationManager { + return new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory() + ), + (string) realpath(__DIR__ . '/../../../../../'), + $repository, + $now !== null ? static fn(): \DateTimeImmutable => $now : null + ); + } + + public function testRegisterInstalledLanguageUpdateUsesInjectedClockForLastUpdate(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->logicalAnd( + $this->stringContains(/** @lang text */ 'UPDATE object_data'), + $this->stringContains("last_update = '2026-01-02 03:04:05'") + )); + + $manager = $this->createManager( + $db, + $this->createMock(InstalledLanguageRepository::class), + new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) + ); + + $manager->registerInstalledLanguage( + 'de', + ['de' => ['obj_id' => 7, 'status' => 'not_installed']], + [] + ); + } + + public function testRegisterInstalledLanguageInsertUsesDbNowNotTheClock(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->logicalAnd( + $this->stringContains(/** @lang text */ 'INSERT INTO object_data'), + $this->stringContains('NOW(),NOW()') + )); + + $manager = $this->createManager( + $db, + $this->createMock(InstalledLanguageRepository::class), + new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) + ); + + $manager->registerInstalledLanguage('de', [], []); + } + + public function testInstallLanguagesSetsLastUpdateOnUninstallUsingInjectedClock(): void + { + $db = $this->createDatabaseMock(); + $calls = []; + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getAvailableLanguages')->willReturn([ + 'fr' => ['obj_id' => 3, 'status' => 'installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + + $manager = $this->createManager( + $db, + $repository, + new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) + ); + + // 'fr' is no longer requested: flushed, bookkeeping row flipped to "not_installed" with a fresh last_update + $result = $manager->installLanguages([]); + + $this->assertTrue($result); + $bookkeeping_update = array_values(array_filter( + $calls, + static fn(string $query): bool => str_contains($query, /** @lang text */ 'UPDATE object_data') + )); + $this->assertCount(1, $bookkeeping_update); + $this->assertStringContainsString("description = 'not_installed'", $bookkeeping_update[0]); + $this->assertStringContainsString("last_update = '2026-01-02 03:04:05'", $bookkeeping_update[0]); + } + + public function testFlushLanguageForInstallationKeepsLocalChanges(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->logicalAnd( + $this->stringContains(/** @lang text */ 'DELETE FROM lng_data'), + $this->stringContains('local_change IS NULL') + )); + + $manager = $this->createManager($db, $this->createMock(InstalledLanguageRepository::class)); + + $manager->flushLanguageForInstallation('de'); + } + + public function testFlushLanguageForUninstallationDeletesEverything(): void + { + $db = $this->createDatabaseMock(); + $calls = []; + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $manager = $this->createManager($db, $this->createMock(InstalledLanguageRepository::class)); + + $manager->flushLanguageForUninstallation('de'); + + $this->assertCount(2, $calls); + $this->assertStringContainsString(/** @lang text */ 'DELETE FROM lng_data', $calls[0]); + $this->assertStringNotContainsString('local_change IS NULL', $calls[0]); + $this->assertStringContainsString(/** @lang text */ 'DELETE FROM lng_modules', $calls[1]); + } + + /** + * Reinstalling a language while removing its local changes must not re-apply the customizing directory's file, + * which would reinstate the data being removed (see ilObjLanguageFolderGUI::uninstallChangesObject()). + */ + public function testInsertLanguageForRemovingLocalChangesIgnoresCustomizingDirectory(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Global Value']]); + $this->writeLangFile($root . '/lang/customizing/ilias_de.lang.local', [['common', 'test', 'Custom Value']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + // no DB local changes are consulted: a clean re-seed has nothing to merge + $repository->expects($this->never())->method('getLocalChanges'); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForRemovingLocalChanges('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + $this->assertStringContainsString("'Global Value'", $insert[0]); + $this->assertStringNotContainsString('Custom Value', $insert[0]); + } finally { + $this->removeDirectory($root); + } + } + + /** + * The ordinary installation path still merges the customizing directory's file on top of the global one. + */ + public function testInsertLanguageForInstallationStillMergesCustomizingDirectory(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Global Value']]); + $this->writeLangFile($root . '/lang/customizing/ilias_de.lang.local', [['common', 'test', 'Custom Value']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository, + static fn(): \DateTimeImmutable => new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) + ); + + $manager->insertLanguageForInstallation('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + $this->assertStringContainsString("'Global Value'", $insert[0]); + $this->assertStringContainsString("'Custom Value'", $insert[0]); + } finally { + $this->removeDirectory($root); + } + } + + /** + * The local-changes lookup depends only on language and file mtime and nothing in the loop writes to the + * database, so it runs once per directory, not once per line of a customizing file; the query count must not + * grow with the file. + */ + public function testLocalChangesAreLookedUpOncePerDirectoryNotPerEntry(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [ + ['common', 'locally_changed', 'Global Value'], + ['common', 'plain', 'Plain Global'], + ]); + + $local_entries = [['common', 'newer_in_db', 'From Local File']]; + for ($i = 0; $i < 200; $i++) { + $local_entries[] = ['common', 'override_' . $i, 'Local ' . $i]; + } + $this->writeLangFile($root . '/lang/customizing/ilias_de.lang.local', $local_entries); + + try { + $calls = 0; + $inserts = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback( + static function (string $query) use (&$inserts): int { + if (str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data')) { + $inserts[] = $query; + } + return 1; + } + ); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturnCallback( + static function (string $lang_key, string $min_date = '') use (&$calls): array { + $calls++; + // no date: the seed of changes already in the database; with a date: changes newer than the customizing file + return $min_date === '' + ? ['common' => ['locally_changed' => 'DB Local Change']] + : ['common' => ['newer_in_db' => 'DB Is Newer']]; + } + ); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository, + static fn(): \DateTimeImmutable => new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) + ); + + $manager->insertLanguageForInstallation('de'); + + // one seed lookup plus one for the single customizing directory, independent of the 201 entries + $this->assertSame(2, $calls); + + $this->assertCount(1, $inserts); + $insert = $inserts[0]; + // a global entry with a local change in the database keeps the database value + $this->assertStringNotContainsString("'Global Value'", $insert); + $this->assertStringContainsString("'Plain Global'", $insert); + // a customizing entry with an even newer database change is not overwritten by the file either + $this->assertStringNotContainsString("'From Local File'", $insert); + $this->assertStringContainsString("'Local 0'", $insert); + $this->assertStringContainsString("'Local 199'", $insert); + } finally { + $this->removeDirectory($root); + } + } + + /** + * insertLanguageForApplyingLocalChanges() reads only the customizing directory: no base content in the INSERT and + * no flush (DELETE FROM lng_data). + */ + public function testInsertLanguageForApplyingLocalChangesOnlyReadsCustomizingDirectory(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Global Value']]); + $this->writeLangFile($root . '/lang/customizing/ilias_de.lang.local', [['common', 'test', 'Custom Value']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->expects($this->once()) + ->method('getLanguageEntries') + ->with('de') + ->willReturn([]); + // insertLanguage()'s "newer change in the DB?" check still calls getLocalChanges() once per local directory; + // the seed itself comes from getLanguageEntries() alone + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForApplyingLocalChanges('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + $this->assertStringContainsString("'Custom Value'", $insert[0]); + $this->assertStringNotContainsString('Global Value', $insert[0]); + + $flush = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'DELETE FROM lng_data') + )); + $this->assertCount(0, $flush); + } finally { + $this->removeDirectory($root); + } + } + + /** + * insertLanguage() rebuilds the lng_modules row from the seed plus the given directories: if the seed held only + * local changes (getLocalChanges()) instead of every stored entry (getLanguageEntries()), a base entry the + * customizing file does not override would vanish from the row. + */ + public function testInsertLanguageForApplyingLocalChangesPreservesUnrelatedSeedEntriesInModulesCache(): void + { + $root = $this->createTempInstallationRoot(); + // only 'overridden' is touched by the customizing file; 'untouched_by_customizing' must survive via the seed + $this->writeLangFile( + $root . '/lang/customizing/ilias_de.lang.local', + [['common', 'overridden', 'New Custom Value']] + ); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->expects($this->once())->method('getLanguageEntries')->with('de')->willReturn([ + 'common' => [ + 'overridden' => 'Old Value', + 'untouched_by_customizing' => 'Still Here', + ], + ]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForApplyingLocalChanges('de'); + + $modules_insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_modules') + )); + $this->assertCount(1, $modules_insert); + $serialized = serialize([ + 'overridden' => 'New Custom Value', + 'untouched_by_customizing' => 'Still Here', + ]); + $this->assertStringContainsString($serialized, $modules_insert[0]); + } finally { + $this->removeDirectory($root); + } + } + + /** + * installLanguages() registers a language that passes validation and is not yet in object_data via the INSERT + * branch of registerInstalledLanguage(). + */ + public function testInstallLanguagesRegistersBrandNewLanguage(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'greeting', 'Hallo']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->expects($this->once())->method('checkLanguage')->with('de')->willReturn(true); + $repository->method('getAvailableLanguages')->willReturn([]); + $repository->method('getLocalLanguages')->willReturn([]); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $result = $manager->installLanguages(['de']); + + $this->assertTrue($result); + + $inserts_object_data = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO object_data') + )); + $this->assertCount(1, $inserts_object_data); + $this->assertStringContainsString("'de'", $inserts_object_data[0]); + $this->assertStringContainsString("'installed'", $inserts_object_data[0]); + $this->assertStringNotContainsString("'installed_local'", $inserts_object_data[0]); + + $keep_local_flush = array_values(array_filter( + $calls, + static fn(string $query): bool => str_contains($query, /** @lang text */ 'DELETE FROM lng_data') + && str_contains($query, 'local_change IS NULL') + )); + $this->assertCount(1, $keep_local_flush); + } finally { + $this->removeDirectory($root); + } + } + + /** + * A language failing checkLanguage() is reported as failed (a list, not `true`) and skipped in the second pass: + * neither re-synced nor flushed, even though it is known in object_data. + */ + public function testInstallLanguagesReportsFailedLanguageAndLeavesItUntouched(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'greeting', 'Hallo']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('checkLanguage')->willReturnMap([ + ['de', true], + ['xx', false], + ]); + $repository->method('getAvailableLanguages')->willReturn([ + // known from a previous installation, its language file has become invalid + 'xx' => ['obj_id' => 5, 'status' => 'installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $result = $manager->installLanguages(['de', 'xx']); + + $this->assertSame(['xx'], $result); + + $inserts_object_data = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO object_data') + )); + $this->assertCount(1, $inserts_object_data); + $this->assertStringContainsString("'de'", $inserts_object_data[0]); + + // 'xx' is known (obj_id 5) but failed validation: no flush, no status update, no resync + foreach ($calls as $query) { + $this->assertStringNotContainsString("obj_id = '5'", $query); + $this->assertStringNotContainsString("lang_key = 'xx'", $query); + } + } finally { + $this->removeDirectory($root); + } + } + + /** + * A known, still requested language gets its bookkeeping row refreshed via registerInstalledLanguage(), without a + * full flushLanguage("all") or a forced "not_installed" status. + */ + public function testInstallLanguagesResyncsAlreadyKnownRequestedLanguage(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_fr.lang', [['common', 'greeting', 'Bonjour']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('checkLanguage')->willReturn(true); + $repository->method('getAvailableLanguages')->willReturn([ + 'fr' => ['obj_id' => 9, 'status' => 'installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $result = $manager->installLanguages(['fr']); + $this->assertTrue($result); + + // no full flush: flushLanguage("all") deletes without a module filter, unlike insertLanguage()'s own + // per-module cleanup ("AND module IN (...)") + $full_flush_deletes = array_values(array_filter( + $calls, + static fn(string $query): bool => $query === "DELETE FROM lng_modules WHERE lang_key = 'fr'" + )); + $this->assertCount(0, $full_flush_deletes); + + foreach ($calls as $query) { + $this->assertStringNotContainsString('not_installed', $query); + } + + // ...but the bookkeeping row is refreshed via UPDATE, keeping "installed" + $object_data_updates = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'UPDATE object_data') + )); + $this->assertCount(1, $object_data_updates); + $this->assertStringContainsString("obj_id = '9'", $object_data_updates[0]); + $this->assertStringContainsString("description = 'installed'", $object_data_updates[0]); + } finally { + $this->removeDirectory($root); + } + } + + /** + * A language already marked "not_installed" is still flushed but gets no redundant + * "UPDATE ... description = 'not_installed'": strpos("not_installed", "installed") is 3, so the guard must be + * `=== 0`, not `!== false` or `>= 0`. + */ + public function testInstallLanguagesDoesNotReUpdateAlreadyNotInstalledLanguage(): void + { + $db = $this->createDatabaseMock(); + $calls = []; + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getAvailableLanguages')->willReturn([ + 'de' => ['obj_id' => 4, 'status' => 'not_installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + + $manager = $this->createManager($db, $repository); + + $result = $manager->installLanguages([]); + + $this->assertTrue($result); + + $this->assertStringContainsString( + "DELETE FROM lng_modules WHERE lang_key = 'de'", + implode("\n", $calls) + ); + + // ...but no UPDATE: it was already "not_installed" + $updates = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'UPDATE object_data') + )); + $this->assertCount(0, $updates); + } + + /** + * A .lang line may carry a "###comment" suffix (e.g. "...#:#Delete Answer###fau: testNav"). The value is truncated + * at "###" and the comment must survive into lng_data.remarks, as in the former + * ilObjLanguageDBAccess::insertLangEntries(). + */ + public function testInsertLanguageStoresCommentSuffixInRemarksColumn(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents( + $root . '/lang/ilias_de.lang', + "\n" + . "assessment#:#discard_answer#:#Delete Answer###fau: testNav\n" + ); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('assessment')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForInstallation('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + + // the value is truncated at "###": the comment must not leak into it + $this->assertStringContainsString("'Delete Answer'", $insert[0]); + $this->assertStringNotContainsString('Delete Answer###', $insert[0]); + + // the comment is preserved in the remarks column, not discarded as NULL + $this->assertStringContainsString( + "'fau: testNav'", + $insert[0], + 'The "###" comment suffix must be stored in the remarks column of lng_data, not discarded.' + ); + } finally { + $this->removeDirectory($root); + } + } + + /** + * A line without a "###" suffix leaves the remarks column NULL, not an empty string or the text "null". + */ + public function testInsertLanguageLeavesRemarksNullWhenLineHasNoCommentSuffix(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Plain Value']]); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForInstallation('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + $this->assertStringContainsString("'Plain Value'", $insert[0]); + // the mock's quote() casts values to string, so NULL shows as an empty quoted string before the closing paren + $this->assertMatchesRegularExpression( + "/,'',''\\)/", + $insert[0], + 'Expected the tuple to end in (...,local_change=\'\',remarks=\'\') for a line without a comment suffix.' + ); + } finally { + $this->removeDirectory($root); + } + } + + /** + * The two-field lines (identifier#:#value) of a component directory are written with the directory's prefix as + * module, not with the first field misread as module. The read path (checkLanguage()) is covered in + * ilSetupLanguageTest. + */ + public function testInsertLanguageUsesDirectoryPrefixAsModuleForComponentFiles(): void + { + $root = $this->createTempInstallationRoot(); + mkdir($root . '/lang/file', 0777, true); + file_put_contents( + $root . '/lang/file/ilias_de.lang', + "\nadd_file#:#Add File\n" + ); + + try { + $calls = []; + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('file')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $this->createPrefixedComponentDirectory('lang/file/', 'file') + ), + $root, + $repository + ); + + $manager->insertLanguageForInstallation('de'); + + $insert = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data') + )); + $this->assertCount(1, $insert); + // module=prefix, identifier=first field, value=second field + $this->assertStringContainsString("('file','add_file','de','Add File'", $insert[0]); + } finally { + $this->removeDirectory($root); + } + } + + private function createPrefixedComponentDirectory(string $path, string $prefix): LanguageFileDirectory + { + return new class ($path, $prefix) implements LanguageFileDirectory { + public function __construct(private string $path, private string $prefix) + { + } + + 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; + } + }; + } + + public function testInsertLanguageInvalidatesTheTranslationCache(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Global Value']]); + + try { + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->once())->method('invalidate')->with('de'); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository, + null, + $cache + ); + + $manager->insertLanguageForInstallation('de'); + } finally { + $this->removeDirectory($root); + } + } + + public function testFlushLanguageForUninstallationInvalidatesTheTranslationCache(): void + { + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->once())->method('invalidate')->with('de'); + + $manager = new LanguageInstallationManager( + $this->createDatabaseMock(), + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + (string) realpath(__DIR__ . '/../../../../../'), + $this->createMock(InstalledLanguageRepository::class), + null, + $cache + ); + + $manager->flushLanguageForUninstallation('de'); + } + + public function testFlushLanguageForInstallationKeepsTheTranslationCache(): void + { + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->never())->method('invalidate'); + + $manager = new LanguageInstallationManager( + $this->createDatabaseMock(), + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + (string) realpath(__DIR__ . '/../../../../../'), + $this->createMock(InstalledLanguageRepository::class), + null, + $cache + ); + + $manager->flushLanguageForInstallation('de'); + } + + private function createTempInstallationRoot(): string + { + $dir = sys_get_temp_dir() . '/ilias_lang_test_' . bin2hex(random_bytes(8)); + mkdir($dir . '/lang/customizing', 0777, true); + return $dir; + } + + /** + * @param list $entries module, identifier, value + */ + private function writeLangFile(string $path, array $entries): void + { + $lines = [""]; + foreach ($entries as [$module, $identifier, $value]) { + $lines[] = "{$module}#:#{$identifier}#:#{$value}"; + } + file_put_contents($path, implode("\n", $lines) . "\n"); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/components/ILIAS/Language/tests/Setup/ilLanguageMetricsCollectedObjectiveTest.php b/components/ILIAS/Language/tests/Setup/ilLanguageMetricsCollectedObjectiveTest.php new file mode 100644 index 000000000000..dc81ba913692 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/ilLanguageMetricsCollectedObjectiveTest.php @@ -0,0 +1,95 @@ +createStub(Setup\Metrics\Storage::class), + $setup_language + ); + $environment = new Setup\ArrayEnvironment([]); + if ($db !== null) { + $environment = $environment->withResource(Setup\Environment::RESOURCE_DATABASE, $db); + } + + (new ReflectionMethod($objective, 'collectFrom')) + ->invoke($objective, $environment, $this->createStub(Setup\Metrics\Storage::class)); + } + + public function testSetupDatabaseIsHandedToIlSetupLanguageBeforeAnyRead(): void + { + $db = $this->createStub(ilDBInterface::class); + $calls = []; + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->expects(self::once())->method('setDbHandler')->with($db) + ->willReturnCallback(static function () use (&$calls): bool { + $calls[] = 'setDbHandler'; + return true; + }); + $setup_language->method('getLocalLanguages')->willReturnCallback(static function () use (&$calls): array { + $calls[] = 'getLocalLanguages'; + return []; + }); + $setup_language->method('getInstalledLanguages')->willReturnCallback(static function () use (&$calls): array { + $calls[] = 'getInstalledLanguages'; + return []; + }); + + $this->collect($setup_language, $db); + + self::assertSame('setDbHandler', $calls[0] ?? null); + self::assertContains('getInstalledLanguages', $calls); + } + + public function testGlobalsAreNotChanged(): void + { + $dic = new ILIAS\DI\Container(); + $GLOBALS['DIC'] = $dic; + unset($GLOBALS['ilDB']); + + $setup_language = $this->createStub(ilSetupLanguage::class); + $setup_language->method('getLocalLanguages')->willReturn([]); + $setup_language->method('getInstalledLanguages')->willReturn([]); + + $this->collect($setup_language, $this->createStub(ilDBInterface::class)); + + self::assertSame($dic, $GLOBALS['DIC']); + self::assertFalse(isset($dic['ilDB'])); + self::assertArrayNotHasKey('ilDB', $GLOBALS); + } + + public function testWithoutSetupDatabaseNothingIsRead(): void + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->expects(self::never())->method('setDbHandler'); + $setup_language->expects(self::never())->method('getInstalledLanguages'); + + $this->collect($setup_language, null); + } +} diff --git a/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php b/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php new file mode 100644 index 000000000000..d57ffbf7038e --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php @@ -0,0 +1,250 @@ +had_global_db = isset($GLOBALS['ilDB']); + $this->previous_global_db = $GLOBALS['ilDB'] ?? null; + } + + protected function tearDown(): void + { + if ($this->had_global_db) { + $GLOBALS['ilDB'] = $this->previous_global_db; + } else { + unset($GLOBALS['ilDB']); + } + } + + /** + * @param list $log collects the tag of every database actually queried + */ + private function createDatabaseMock(string $tag, array &$log): ilDBInterface + { + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); + $db->method('like')->willReturn('1=1'); + $db->method('now')->willReturn('NOW()'); + $db->method('nextId')->willReturn(1); + $db->method('manipulate')->willReturnCallback(static function () use ($tag, &$log): int { + $log[] = $tag; + return 1; + }); + $db->method('query')->willReturnCallback(function () use ($tag, &$log) { + $log[] = $tag; + return $this->createStub(ilDBStatement::class); + }); + + // a single installed language, then exhausted; $rows must be captured by reference, or the + // "while ($row = fetchObject())" loops never end + $rows = [(object) ['title' => 'de', 'obj_id' => 7, 'description' => 'installed']]; + $db->method('fetchObject')->willReturnCallback(static function () use (&$rows) { + return array_shift($rows); + }); + + return $db; + } + + public function testInjectedDatabaseTakesPrecedenceOverTheGlobal(): void + { + $log = []; + $GLOBALS['ilDB'] = $this->createDatabaseMock('GLOBAL', $log); + + $setup_language = new ilSetupLanguage('de'); + $setup_language->setDbHandler($this->createDatabaseMock('INJECTED', $log)); + + $setup_language->getInstalledLanguages(); + $setup_language->registerInstalledLanguage('de', [], []); + + $this->assertNotEmpty($log, 'no database was queried at all'); + $this->assertSame( + ['INJECTED'], + array_values(array_unique($log)), + 'the global was used even though a database had been injected' + ); + } + + public function testAchieveNeedsNoGlobalDatabaseAtAll(): void + { + $log = []; + unset($GLOBALS['ilDB']); + + $objective = new ilLanguagesInstalledAndUpdatedObjective(new ilSetupLanguage('en')); + $environment = new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->createDatabaseMock('INJECTED', $log), + ]); + + $objective->achieve($environment); + + $this->assertFalse(isset($GLOBALS['ilDB']), 'the global database must not be (re)created'); + $this->assertSame(['INJECTED'], array_values(array_unique($log))); + } + + public function testGetLabelNeedsNoDatabase(): void + { + unset($GLOBALS['ilDB']); + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->expects($this->never())->method('getInstalledLanguages'); + + $objective = new ilLanguagesInstalledAndUpdatedObjective($setup_language); + + $this->assertSame('Install/Update languages', $objective->getLabel()); + } + + public function testIsApplicableReadsTheLanguagesForTheLabelFromTheSetupDatabase(): void + { + $log = []; + unset($GLOBALS['ilDB']); + $objective = new ilLanguagesInstalledAndUpdatedObjective(new ilSetupLanguage('en')); + $environment = new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->createDatabaseMock('INJECTED', $log), + ]); + + $this->assertTrue($objective->isApplicable($environment)); + $this->assertSame('Install/Update languages de', $objective->getLabel()); + $this->assertSame(['INJECTED'], array_values(array_unique($log))); + } + + public function testAchieveReusesTheLanguagesDeterminedInIsApplicable(): void + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->expects($this->once())->method('getInstalledLanguages')->willReturn(['de', 'fr']); + $install_language = $this->createMock(\ILIAS\Language\Activities\InstallLanguage::class); + $install_language->expects($this->once())->method('perform') + ->with(['language_keys' => ['de', 'fr'], 'mode' => \ILIAS\Language\Activities\InstallLanguage::MODE_INSTALL]); + $update_language = $this->createMock(\ILIAS\Language\Activities\UpdateLanguage::class); + $update_language->expects($this->once())->method('perform')->with(['language_keys' => ['de', 'fr']]); + $objective = new ilLanguagesInstalledAndUpdatedObjective($setup_language, $install_language, $update_language); + $environment = new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->createStub(ilDBInterface::class), + ]); + + $objective->isApplicable($environment); + $objective->achieve($environment); + + $this->assertSame('Install/Update languages de, fr', $objective->getLabel()); + } + + /** + * @param list $log collects the language key every write method was called with, in call order + */ + private function createSetupLanguageMock(array $installed_language_keys, array &$log): MockObject&ilSetupLanguage + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys); + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn([]); + $setup_language->method('getLocalLanguages')->willReturn([]); + $setup_language->method('getInvalidLocalLanguageFiles')->willReturn([]); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->method('flushLanguageForInstallation')->willReturnCallback( + static function (string $lang_key) use (&$log): void { + $log[] = $lang_key; + } + ); + + return $setup_language; + } + + /** + * installLanguages() is protected, so it is invoked via reflection, like ilSetupLanguageTest::callCheckLanguage(). + * + * @param list $language_keys + */ + private function invokeInstallLanguages(ilLanguagesInstalledAndUpdatedObjective $objective, array $language_keys): void + { + (new ReflectionMethod($objective, 'installLanguages'))->invoke($objective, $language_keys); + } + + /** + * A mixed list of installed and not installed keys must flush and reinstall each: the new one via InstallLanguage, + * the installed one via UpdateLanguage (both built via forSetup() around the same mocked ilSetupLanguage). + */ + public function testInstallLanguagesActuallyFlushesBothTheNewlyInstalledAndTheAlreadyInstalledLanguage(): void + { + $log = []; + $setup_language = $this->createSetupLanguageMock(['de'], $log); + + $objective = new ilLanguagesInstalledAndUpdatedObjective($setup_language); + $this->invokeInstallLanguages($objective, ['de', 'fr']); + + // 'fr' is flushed by InstallLanguage, then 'de' by UpdateLanguage: installLanguages() runs InstallLanguage first + $this->assertSame(['fr', 'de'], $log); + } + + /** + * The documented "harmless double cycle": a language just installed by InstallLanguage is flushed again by + * UpdateLanguage, because getInstalledLanguages() now reports it as installed. Intentional, see + * ilLanguagesInstalledAndUpdatedObjective::installLanguages(). + */ + public function testFreshlyInstalledLanguageIsImmediatelyRefreshedAgainByUpdateLanguage(): void + { + $log = []; + $newly_installed = []; + + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturnCallback( + static function () use (&$newly_installed): array { + return array_merge(['de'], $newly_installed); + } + ); + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn([]); + $setup_language->method('getLocalLanguages')->willReturn([]); + $setup_language->method('getInvalidLocalLanguageFiles')->willReturn([]); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->method('flushLanguageForInstallation')->willReturnCallback( + static function (string $lang_key) use (&$log): void { + $log[] = $lang_key; + } + ); + // the write that makes 'fr' visible as installed to later getInstalledLanguages() calls, like a real INSERT + $setup_language->method('registerInstalledLanguage')->willReturnCallback( + static function (string $lang_key) use (&$newly_installed): void { + $newly_installed[] = $lang_key; + } + ); + + $objective = new ilLanguagesInstalledAndUpdatedObjective($setup_language); + $this->invokeInstallLanguages($objective, ['de', 'fr']); + + // 'fr' is flushed by InstallLanguage; UpdateLanguage then sees ['de', 'fr'] and flushes both again + $this->assertSame(['fr', 'de', 'fr'], $log); + } +} diff --git a/components/ILIAS/Language/tests/bootstrap.php b/components/ILIAS/Language/tests/bootstrap.php index 96d6b595f2ad..74cba2315b78 100755 --- a/components/ILIAS/Language/tests/bootstrap.php +++ b/components/ILIAS/Language/tests/bootstrap.php @@ -18,4 +18,4 @@ declare(strict_types=1); require_once 'vendor/composer/vendor/autoload.php'; -require_once 'ilLanguageBaseTest.php'; +require_once 'ilLanguageBaseTestCase.php'; diff --git a/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php b/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php index 255774a52e6e..816cf5bea6f0 100644 --- a/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php +++ b/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php @@ -20,6 +20,8 @@ use PHPUnit\Framework\TestCase; use ILIAS\DI\Container; +use ILIAS\AccessControl\PublicInterface\RBAC; +use ILIAS\Language\LanguageFolder; /** * Class ilLanguageBaseTest @@ -45,4 +47,20 @@ protected function setGlobalVariable(string $name, $value): void return $GLOBALS[$name]; }; } + + protected function rbacOf(\ilRbacSystem $system): RBAC + { + $rbac = $this->createStub(RBAC::class); + $rbac->method('system')->willReturn($system); + + return $rbac; + } + + protected function languageFolder(int $ref_id = 0): LanguageFolder + { + $language_folder = $this->createStub(LanguageFolder::class); + $language_folder->method('getRefId')->willReturn($ref_id); + + return $language_folder; + } } diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index 137a46ddd372..46e2055f28c5 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -20,11 +20,10 @@ use ILIAS\Refinery\Factory as Refinery; use ILIAS\Data\Factory as DataFactory; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\Setup\Objective\NullObjective; -/** - * Class ilLanguageSetupAgentTest - */ class ilLanguageSetupAgentTest extends ilLanguageBaseTestCase { /** @@ -36,8 +35,23 @@ protected function setUp(): void { $refinery = new Refinery($this->createMock(DataFactory::class), $this->createMock(\ilLanguage::class)); $setup_language = $this->createMock(ilSetupLanguage::class); - - $this->obj = new \ilLanguageSetupAgent($refinery, $setup_language); + $install_language = new InstallLanguage( + $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + $setup_language, + $this->languageFolder() + ); + $update_language = new UpdateLanguage( + $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + $setup_language, + $this->languageFolder() + ); + + $this->obj = new \ilLanguageSetupAgent( + $refinery, + $setup_language, + $install_language, + $update_language + ); } public function testCreate(): void diff --git a/components/ILIAS/Language/tests/ilObjLanguageDBAccessTest.php b/components/ILIAS/Language/tests/ilObjLanguageDBAccessTest.php deleted file mode 100755 index e7158ed79826..000000000000 --- a/components/ILIAS/Language/tests/ilObjLanguageDBAccessTest.php +++ /dev/null @@ -1,107 +0,0 @@ - - */ -class ilObjLanguageDBAccessTest extends ilLanguageBaseTestCase -{ - private ilDBInterface $ilDB; - - protected function setUp(): void - { - $ilDB_mock = $this->getMockBuilder(ilDBInterface::class)->getMock(); - $this->ilDB = $ilDB_mock; - } - - public function testCreate(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document", "administration#:#adm_achievements#:#Achievements"]; - $local_changes = []; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - $this->assertInstanceOf(\ilObjLanguageDBAccess::class, $ilObjLanguageDBAccess); - } - - public function testInsertLangEntriesReturnsArray(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document", "administration#:#adm_achievements#:#Achievements"]; - $local_changes = []; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - - $result = $ilObjLanguageDBAccess->insertLangEntries("lang/ilias_en.lang"); - - $this->assertIsArray($result); - } - - public function testInsertLangEntriesReturnedArrayHasValuesFromContent(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document"]; - $local_changes = []; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - $result = $ilObjLanguageDBAccess->insertLangEntries("lang/ilias_en.lang"); - - $this->assertArrayHasKey("acc", $result); - $this->assertArrayHasKey("acc_add_document_btn_label", $result["acc"]); - $this->assertEquals("Add Document", $result["acc"]["acc_add_document_btn_label"]); - } - - public function testInsertLangEntriesLocalChangesAreNotOverwritten(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document"]; - $local_changes = ["acc" => ["acc_add_document_btn_label" => "Add Documents"]]; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - $result = $ilObjLanguageDBAccess->insertLangEntries("lang/ilias_en.lang"); - - $this->assertEquals("Add Documents", $result["acc"]["acc_add_document_btn_label"]); - } - - public function testInsertLangEntriesManipulateCalledOnce(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document"]; - $local_changes = []; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - - $this->ilDB->expects($this->once())->method("manipulate"); - $result = $ilObjLanguageDBAccess->insertLangEntries("lang/ilias_en.lang"); - } - - public function testInsertLangEntriesManipulateCalledNeverWhenEveryContentHasALocalChange(): void - { - $key = "en"; - $content = ["acc#:#acc_add_document_btn_label#:#Add Document"]; - $local_changes = ["acc" => ["acc_add_document_btn_label" => "Add Documents"]]; - - $ilObjLanguageDBAccess = new ilObjLanguageDBAccess($this->ilDB, $key, $content, $local_changes); - - $this->ilDB->expects($this->never())->method("manipulate"); - $result = $ilObjLanguageDBAccess->insertLangEntries("lang/ilias_en.lang"); - } -} diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php new file mode 100644 index 000000000000..5c8357943ba0 --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php @@ -0,0 +1,481 @@ +stubbed_activity_error_logger = null; + } + + /** Set by stubLoggerLangError(), attached to the GUI's `activity_error_logger`; reset in tearDown(). */ + private ?\ilLogger $stubbed_activity_error_logger = null; + + /** The logger of stubLoggerLangError(), or a permissive stub for tests expecting no logging. */ + private function activityErrorLoggerForGui(): \ilLogger + { + return $this->stubbed_activity_error_logger ?? $this->createMock(\ilLogger::class); + } + + private function setProperty(object $object, string $property_name, mixed $value): void + { + $property = new ReflectionProperty($object, $property_name); + $property->setValue($object, $value); + } + + /** Every GUI reaching maybePerformAs() needs a `ui_factory` (typed property of ilObjectGUI); a bare mock suffices. */ + private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&MockObject + { + $ui_factory = $this->createMock(\ILIAS\UI\Factory::class); + $ui_factory->method('input')->willReturn( + $this->createMock(\ILIAS\UI\Component\Input\Factory::class) + ); + + return $ui_factory; + } + + /** `private readonly` properties of the leaf class are not found on a PHPUnit mock subclass by a plain ReflectionProperty. */ + private function setReadonlyPropertyDeclaredOnGuiClass( + ilObjLanguageExtGUI $gui, + string $property_name, + mixed $value + ): void { + (new ReflectionClass(ilObjLanguageExtGUI::class)) + ->getProperty($property_name) + ->setValue($gui, $value); + } + + private function createLanguageMockReturningTopicAsIs(array $installed_languages = []): ilLanguage&MockObject + { + $lng = $this->createMock(ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + $lng->method('getInstalledLanguages')->willReturn($installed_languages); + + return $lng; + } + + /** + * Only the ": \n" prefix of the logged string is asserted, the trace is environment-dependent. + * The mock is wired onto the GUI by the createGuiFor*() factories. + * + * @param non-empty-string $expected_logged_message + * @param class-string<\Throwable> $expected_exception_class + */ + private function stubLoggerLangError( + string $expected_logged_message, + string $expected_exception_class = \RuntimeException::class + ): void { + $expected_prefix = $expected_exception_class . ': ' . $expected_logged_message . "\n"; + + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->once())->method('error')->with( + $this->callback( + static fn(string $logged): bool => str_starts_with($logged, $expected_prefix) + ) + ); + + $this->stubbed_activity_error_logger = $logger; + } + + private function mockHttpWithParsedBody(array $parsed_body): \ILIAS\HTTP\GlobalHttpState + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($parsed_body); + + $http = $this->createMock(\ILIAS\HTTP\GlobalHttpState::class); + $http->method('request')->willReturn($request); + + return $http; + } + + private function createFakeLanguageObject(string $key): ilObjLanguageExt&MockObject + { + /** @var ilObjLanguageExt&MockObject $object */ + $object = $this->createMock(ilObjLanguageExt::class); + $object->key = $key; + + return $object; + } + + /** + * @return ilObjLanguageExtGUI&MockObject + */ + private function createGuiForSaveSettings( + SetLanguageTranslationEnabled $set_language_translation_enabled, + \ILIAS\HTTP\GlobalHttpState $http, + ilObjUser $user, + ilGlobalTemplateInterface $tpl, + ?ilLanguage $lng = null + ): ilObjLanguageExtGUI { + /** @var ilObjLanguageExtGUI&MockObject $gui */ + $gui = $this->getMockBuilder(ilObjLanguageExtGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['initNewSettingsForm']) + ->getMock(); + + $form = $this->createMock(ilPropertyFormGUI::class); + $form->method('getHTML')->willReturn(''); + $gui->method('initNewSettingsForm')->willReturn($form); + + $this->setProperty($gui, 'http', $http); + $this->setProperty($gui, 'user', $user); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'lng', $lng ?? $this->createLanguageMockReturningTopicAsIs()); + $this->setProperty($gui, 'object', $this->createFakeLanguageObject('de')); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'set_language_translation_enabled', + $set_language_translation_enabled + ); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'activity_error_logger', + $this->activityErrorLoggerForGui() + ); + + return $gui; + } + + /** A non-empty "translation" POST value means the checkbox was checked; parameters come from $this->object->key. */ + public function testSaveSettingsObjectBuildsEnabledTrueFromANonEmptyTranslationPostValue(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createMock(SetLanguageTranslationEnabled::class); + $set_language_translation_enabled->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_key' => 'de', 'enabled' => true]) + ->willReturn(new ResultOk(['changed' => true])); + + $gui = $this->createGuiForSaveSettings( + $set_language_translation_enabled, + $this->mockHttpWithParsedBody(['translation' => '1']), + $user, + $this->createMock(ilGlobalTemplateInterface::class) + ); + + $gui->saveSettingsObject(); + } + + /** An unchecked HTML checkbox is not submitted: a missing "translation" key builds enabled=false. */ + public function testSaveSettingsObjectBuildsEnabledFalseWhenTranslationPostKeyIsMissing(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createMock(SetLanguageTranslationEnabled::class); + $set_language_translation_enabled->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_key' => 'de', 'enabled' => false]) + ->willReturn(new ResultOk(['changed' => false])); + + $gui = $this->createGuiForSaveSettings( + $set_language_translation_enabled, + $this->mockHttpWithParsedBody([]), + $user, + $this->createMock(ilGlobalTemplateInterface::class) + ); + + $gui->saveSettingsObject(); + } + + public function testSaveSettingsObjectShowsSuccessMessageWhenResultReportsChangedTrue(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createMock(SetLanguageTranslationEnabled::class); + $set_language_translation_enabled->method('maybePerformAs')->willReturn(new ResultOk(['changed' => true])); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('success', 'settings_saved'); + + $gui = $this->createGuiForSaveSettings( + $set_language_translation_enabled, + $this->mockHttpWithParsedBody(['translation' => '1']), + $user, + $tpl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->saveSettingsObject(); + } + + /** Only shown if the value changed (see 'changed' in SetLanguageTranslationEnabled::getOutputDescription()). */ + public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFalse(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createMock(SetLanguageTranslationEnabled::class); + $set_language_translation_enabled->method('maybePerformAs')->willReturn(new ResultOk(['changed' => false])); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->never())->method('setOnScreenMessage'); + + $gui = $this->createGuiForSaveSettings( + $set_language_translation_enabled, + $this->mockHttpWithParsedBody(['translation' => '1']), + $user, + $tpl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->saveSettingsObject(); + } + + /** saveSettingsObject() never redirects; a \Throwable still goes through activityErrorMessage(). */ + public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirectsOnThrowableError(): void + { + $this->stubLoggerLangError('boom'); + + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createMock(SetLanguageTranslationEnabled::class); + $set_language_translation_enabled->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException('boom')) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('failure', 'action_aborted'); + + $gui = $this->createGuiForSaveSettings( + $set_language_translation_enabled, + $this->mockHttpWithParsedBody(['translation' => '1']), + $user, + $tpl, + $this->createLanguageMockReturningTopicAsIs() + ); + + // no $ctrl injected: a redirect would fatally error instead of silently passing + $gui->saveSettingsObject(); + } + + /** + * @return ilObjLanguageExtGUI&MockObject + */ + private function createGuiForSaveNewEntry( + AddLanguageEntry $add_language_entry, + ilCtrl $ctrl, + ilObjUser $user, + ilGlobalTemplateInterface $tpl, + ilPropertyFormGUI&MockObject $form, + ilLanguage $lng + ): ilObjLanguageExtGUI { + /** @var ilObjLanguageExtGUI&MockObject $gui */ + $gui = $this->getMockBuilder(ilObjLanguageExtGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['initAddNewEntryForm']) + ->getMock(); + + $gui->method('initAddNewEntryForm')->willReturn($form); + + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'user', $user); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'add_language_entry', $add_language_entry); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'activity_error_logger', + $this->activityErrorLoggerForGui() + ); + + return $gui; + } + + private function mockCheckedFormWithInputs(array $inputs): ilPropertyFormGUI&MockObject + { + $form = $this->createMock(ilPropertyFormGUI::class); + $form->method('checkInput')->willReturn(true); + $form->method('getInput')->willReturnCallback( + static fn(string $name): mixed => $inputs[$name] ?? '' + ); + + return $form; + } + + /** 'translations' is keyed by every installed language, values trimmed from the form's "trans_". */ + public function testSaveNewEntryObjectBuildsModuleIdentifierAndTranslationsFromFormAcrossAllInstalledLanguages(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createMock(AddLanguageEntry::class); + $add_language_entry->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, [ + 'module' => 'common', + 'identifier' => 'sometopic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + ]) + ->willReturn(new ResultOk(['added_language_keys' => ['de', 'en']])); + + $form = $this->mockCheckedFormWithInputs([ + 'mod' => 'common', + 'id' => 'sometopic', + 'trans_de' => ' Hallo ', + 'trans_en' => ' Hello ', + ]); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $this->createMock(ilCtrl::class), + $user, + $this->createMock(ilGlobalTemplateInterface::class), + $form, + $this->createLanguageMockReturningTopicAsIs(['de', 'en']) + ); + + $gui->saveNewEntryObject(); + } + + public function testSaveNewEntryObjectShowsSuccessMessageAndRedirectsToViewOnSuccess(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createMock(AddLanguageEntry::class); + $add_language_entry->method('maybePerformAs')->willReturn( + new ResultOk(['added_language_keys' => ['de']]) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('success', 'settings_saved', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageExtGUI::class), + 'view' + ); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $ctrl, + $user, + $tpl, + $this->mockCheckedFormWithInputs(['mod' => 'common', 'id' => 'sometopic']), + $this->createLanguageMockReturningTopicAsIs(['de']) + ); + + $gui->saveNewEntryObject(); + } + + /** A \Throwable shows the generic logged message and redirects to "view" without reaching the success path. */ + public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError(): void + { + $this->stubLoggerLangError('boom'); + + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createMock(AddLanguageEntry::class); + $add_language_entry->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException('boom')) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('failure', 'action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageExtGUI::class), + 'view' + ); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $ctrl, + $user, + $tpl, + $this->mockCheckedFormWithInputs(['mod' => 'common', 'id' => 'sometopic']), + $this->createLanguageMockReturningTopicAsIs(['de']) + ); + + // a dropped `return` after the redirect would run the success path too and fail the once() expectations + $gui->saveNewEntryObject(); + } + + /** An input error (4xx other than 403) re-shows the form with the entered values instead of redirecting. */ + public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPostedValuesOnAnInputError(): void + { + $user = $this->createMock(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createMock(AddLanguageEntry::class); + $add_language_entry->method('maybePerformAs')->willReturn( + new ResultError(new \ILIAS\Language\Activities\InvalidInputException('A value is required for: en.')) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with( + 'failure', + 'A value is required for: en.' + ); + + // no redirect: the form is re-rendered in this request + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->never())->method('redirect'); + + $form = $this->mockCheckedFormWithInputs(['mod' => 'common', 'id' => 'sometopic']); + $form->expects($this->once())->method('setValuesByPost'); + // addNewEntryObject($form) is the re-render path under test; its `global $DIC["tpl"]` access is stubbed + $form->method('getHTML')->willReturn(''); + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['tpl'] = $this->createMock(ilGlobalTemplateInterface::class); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $ctrl, + $user, + $tpl, + $form, + $this->createLanguageMockReturningTopicAsIs(['de', 'en']) + ); + // addNewEntryObject() also checks the "eid" query parameter: stubbed as absent + $query_wrapper = $this->createMock(\ILIAS\HTTP\Wrapper\ArrayBasedRequestWrapper::class); + $query_wrapper->method('has')->willReturn(false); + $wrapper_factory = $this->createMock(\ILIAS\HTTP\Wrapper\WrapperFactory::class); + $wrapper_factory->method('query')->willReturn($query_wrapper); + $http = $this->createMock(\ILIAS\HTTP\GlobalHttpState::class); + $http->method('wrapper')->willReturn($wrapper_factory); + $this->setProperty($gui, 'http', $http); + + $gui->saveNewEntryObject(); + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php new file mode 100644 index 000000000000..0503f0f2419b --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -0,0 +1,1382 @@ +stubbed_activity_error_logger = null; + } + + /** + * activityErrorMessage() logs the raw \Throwable via `activity_error_logger` before returning the generic text. + * Only the ": \n" prefix is asserted, the trace is environment-dependent. + * + * @param non-empty-string $expected_logged_message the raw message, not the generic text shown to the user + * @param class-string<\Throwable> $expected_exception_class + */ + private function stubLoggerLangError( + string $expected_logged_message, + string $expected_exception_class = \RuntimeException::class + ): void { + $expected_prefix = $expected_exception_class . ': ' . $expected_logged_message . "\n"; + + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->once())->method('error')->with( + $this->callback( + static fn(string $logged): bool => str_starts_with($logged, $expected_prefix) + ) + ); + + $this->stubbed_activity_error_logger = $logger; + } + + /** The logger of stubLoggerLangError(), or a permissive stub for tests expecting no logging. */ + private function activityErrorLoggerForGui(): \ilLogger + { + return $this->stubbed_activity_error_logger ?? $this->createMock(\ilLogger::class); + } + + private function createGuiWithCollaborators( + InstallLanguage $install_language, + ilGlobalTemplateInterface $tpl, + ilCtrl $ctrl, + ilLanguage $lng + ): ilObjLanguageFolderGUI { + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $this->setProperty($gui, 'install_language', $install_language); + $this->setProperty($gui, 'current_user_id', 6); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + + return $gui; + } + + /** Every GUI reaching maybePerformAs() needs a `ui_factory` (typed property of ilObjectGUI); a bare mock suffices. */ + private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&\PHPUnit\Framework\MockObject\MockObject + { + $ui_factory = $this->createMock(\ILIAS\UI\Factory::class); + $ui_factory->method('input')->willReturn( + $this->createMock(\ILIAS\UI\Component\Input\Factory::class) + ); + + return $ui_factory; + } + + private function setProperty(object $object, string $property_name, mixed $value): void + { + $property = new ReflectionProperty($object, $property_name); + $property->setValue($object, $value); + } + + private function createLanguageMockReturningTopicAsIs(): ilLanguage&\PHPUnit\Framework\MockObject\MockObject + { + $lng = $this->createMock(ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + + return $lng; + } + + /** @return ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject */ + private function createGuiWithMockedCheckPermission(): ilObjLanguageFolderGUI + { + /** @var ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject $gui */ + $gui = $this->getMockBuilder(ilObjLanguageFolderGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['checkPermission']) + ->getMock(); + + $this->setProperty($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); + $this->setProperty($gui, 'tpl', $this->createMock(ilGlobalTemplateInterface::class)); + $this->setProperty($gui, 'ctrl', $this->createMock(ilCtrl::class)); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + // a readonly property of the leaf class is not found on a mock via a plain ReflectionProperty + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'current_user_id', 6); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'activity_error_logger', + $this->activityErrorLoggerForGui() + ); + + return $gui; + } + + private function setReadonlyPropertyDeclaredOnGuiClass( + ilObjLanguageFolderGUI $gui, + string $property_name, + mixed $value + ): void { + (new ReflectionClass(ilObjLanguageFolderGUI::class)) + ->getProperty($property_name) + ->setValue($gui, $value); + } + + /** checkPermission('write') is the first statement; the Activity enforces the same permission, both must hold. */ + public function testUninstallObjectChecksWritePermissionBeforeActing(): void + { + $gui = $this->createGuiWithMockedCheckPermission(); + $gui->expects($this->once())->method('checkPermission')->with('write'); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultOk($this->uninstallPerformResult([], [], [], [])) + ); + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'uninstall_language', $uninstall_language); + + $gui->uninstallObject([]); + } + + /** installObject() checks 'write' before any work, like its siblings. */ + public function testInstallObjectMustCheckWritePermissionBeforeActing(): void + { + $gui = $this->createGuiWithMockedCheckPermission(); + $gui->expects($this->once())->method('checkPermission')->with('write'); + + $install_language = $this->createMock(InstallLanguage::class); + $install_language->method('maybePerformAs')->willReturn(new ResultOk($this->emptyPerformResult())); + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'install_language', $install_language); + + // empty $ids avoid the legacy `new ilObjLanguage(...)` coupling + $gui->installObject([], InstallLanguage::MODE_INSTALL); + } + + /** refreshSelectedObject() variant. */ + public function testRefreshSelectedObjectMustCheckWritePermissionBeforeActing(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $gui = $this->createGuiWithMockedCheckPermission(); + $gui->expects($this->once())->method('checkPermission')->with('write'); + + $update_language = $this->createMock(UpdateLanguage::class); + $update_language->method('maybePerformAs')->willReturn(new ResultOk($this->updatePerformResult([], []))); + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'update_language', $update_language); + + $gui->refreshSelectedObject([]); + } + + /** uninstallChangesObject() variant; the Activity enforces the same permission, both must hold. */ + public function testUninstallChangesObjectMustCheckWritePermissionBeforeActing(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $gui = $this->createGuiWithMockedCheckPermission(); + $gui->expects($this->once())->method('checkPermission')->with('write'); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultOk($this->removeLocalChangesPerformResult([], [], [])) + ); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'remove_local_language_changes', + $remove_local_language_changes + ); + + $gui->uninstallChangesObject([]); + } + + /** An empty selection shows 'no_checkbox' and redirects without calling the Activity; the Result handling is covered by InstallLanguageTest. */ + public function testInstallObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity(): void + { + $install_language = $this->createMock(InstallLanguage::class); + $install_language->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'no_checkbox', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithCollaborators( + $install_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->installObject([], InstallLanguage::MODE_INSTALL); + } + + /** + * @return array{installed_language_keys: list, installed_with_local_language_keys: list, already_installed_language_keys: list, not_installed_language_keys: list, invalid_local_language_files: list} + */ + private function emptyPerformResult(): array + { + return [ + 'installed_language_keys' => [], + 'installed_with_local_language_keys' => [], + 'already_installed_language_keys' => [], + 'not_installed_language_keys' => [], + 'invalid_local_language_files' => [], + ]; + } + + private function createGuiWithUpdateLanguageCollaborators( + UpdateLanguage $update_language, + ilGlobalTemplateInterface $tpl, + ilCtrl $ctrl, + ilLanguage $lng + ): ilObjLanguageFolderGUI { + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $this->setProperty($gui, 'update_language', $update_language); + $this->setProperty($gui, 'current_user_id', 6); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + + return $gui; + } + + /** refreshSelectedObject() always calls ilObjLanguage::refreshPlugins(): no plugins keep this a unit test. */ + private function stubComponentRepositoryWithNoPlugins(): void + { + $repository = $this->createMock(ilComponentRepository::class); + $repository->method('getPlugins')->willReturn(new ArrayIterator([])); + + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['component.repository'] = $repository; + } + + /** @return array{updated_language_keys: list, not_installed_language_keys: list} */ + private function updatePerformResult(array $updated, array $not_installed): array + { + return [ + 'updated_language_keys' => $updated, + 'not_installed_language_keys' => $not_installed, + ]; + } + + /** An empty selection shows 'no_checkbox' and redirects without calling the Activity; the Result handling is covered by UpdateLanguageTest. */ + public function testRefreshSelectedObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity(): void + { + $update_language = $this->createMock(UpdateLanguage::class); + $update_language->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'no_checkbox', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithUpdateLanguageCollaborators( + $update_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->refreshSelectedObject([]); + } + + private function createGuiWithUninstallLanguageCollaborators( + UninstallLanguage $uninstall_language, + ilGlobalTemplateInterface $tpl, + ilCtrl $ctrl, + ilLanguage $lng + ): ilObjLanguageFolderGUI { + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $this->setProperty($gui, 'uninstall_language', $uninstall_language); + $this->setProperty($gui, 'current_user_id', 6); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + + return $gui; + } + + /** + * @return array{uninstalled_language_keys: list, system_language_keys: list, user_language_keys: list, not_installed_language_keys: list} + */ + private function uninstallPerformResult( + array $uninstalled, + array $system, + array $user, + array $not_installed + ): array { + return [ + 'uninstalled_language_keys' => $uninstalled, + 'system_language_keys' => $system, + 'user_language_keys' => $user, + 'not_installed_language_keys' => $not_installed, + ]; + } + + public function testUninstallObjectEmbedsTheThrowableMessageFromAnErrorResultIntoTheFailureMessage(): void + { + $exception_message = 'boom'; + $this->stubLoggerLangError($exception_message); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException($exception_message)) + ); + + // uninstallObject() wraps the message as "{error}
action_aborted". + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + public function testUninstallObjectShowsTheNoPermissionMessageForA403ErrorResult(): void + { + $error_string = 'msg_no_perm_write'; + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultError(new \DomainException('internal wording', 403)) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', $this->stringContains($error_string), true); + + $ctrl = $this->createMock(ilCtrl::class); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + public function testUninstallObjectRedirectsToViewAfterAnErrorResultAndDoesNotContinueToTheSuccessPath(): void + { + $this->stubLoggerLangError('boom'); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException('boom')) + ); + + // a dropped `return` after the redirect would reach `$result->value()`, which throws on an Error + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage'); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + /** $ids needs a real database to resolve, so only empty $ids is used; the mock returns the error the real Activity would. */ + public function testUninstallObjectPassesOnlyLanguageKeysWithoutAModeKeyToMaybePerformAs(): void + { + $this->stubLoggerLangError('At least one language key is required.', \InvalidArgumentException::class); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_keys' => []]) + ->willReturn(new ResultError( + new \InvalidArgumentException('At least one language key is required.') + )); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('failure', $this->anything(), true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + public function testUninstallObjectSetsSuccessMessageWhenOnlyUninstalledLanguageKeysIsNonEmpty(): void + { + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultOk($this->uninstallPerformResult(['de'], [], [], [])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('success', 'meta_l_de uninstalled', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + /** setOnScreenMessage() keeps one message per type: the three "nothing changed" buckets must be combined into one 'info' call. */ + public function testUninstallObjectCombinesSystemUserAndNotInstalledIntoOneInfoMessage(): void + { + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn(new ResultOk( + $this->uninstallPerformResult([], ['de'], ['en'], ['fr']) + )); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with( + 'info', + 'cannot_uninstall_systemlanguage: meta_l_de
' + . 'cannot_uninstall_language_in_use: meta_l_en
' + . 'languages_already_uninstalled: meta_l_fr', + true + ); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + /** A mix of uninstalled and "nothing changed" languages sets the success and the combined info message, neither clobbering the other. */ + public function testUninstallObjectSetsBothSuccessAndInfoMessagesWhenBothCategoriesAreNonEmpty(): void + { + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn(new ResultOk( + $this->uninstallPerformResult(['de'], ['en'], [], []) + )); + + $captured_calls = []; + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->exactly(2)) + ->method('setOnScreenMessage') + ->willReturnCallback(function (string $type, string $message, bool $keep) use (&$captured_calls): void { + $captured_calls[] = [$type, $message]; + }); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + + self::assertCount(2, $captured_calls); + self::assertSame(['success', 'meta_l_de uninstalled'], $captured_calls[0]); + self::assertSame(['info', 'cannot_uninstall_systemlanguage: meta_l_en'], $captured_calls[1]); + } + + /** With every bucket empty no message appears, not even an empty one. */ + public function testUninstallObjectSetsNoMessageWhenAllBucketsAreEmpty(): void + { + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->method('maybePerformAs')->willReturn( + new ResultOk($this->uninstallPerformResult([], [], [], [])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->never())->method('setOnScreenMessage'); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject([]); + } + + private function createGuiWithRemoveLocalLanguageChangesCollaborators( + RemoveLocalLanguageChanges $remove_local_language_changes, + ilGlobalTemplateInterface $tpl, + ilCtrl $ctrl, + ilLanguage $lng + ): ilObjLanguageFolderGUI { + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $this->setProperty($gui, 'remove_local_language_changes', $remove_local_language_changes); + $this->setProperty($gui, 'current_user_id', 6); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + + return $gui; + } + + /** + * @return array{removed_local_changes_language_keys: list, invalid_language_file_keys: list, not_installed_language_keys: list} + */ + private function removeLocalChangesPerformResult( + array $removed_local_changes, + array $invalid_language_file, + array $not_installed + ): array { + return [ + 'removed_local_changes_language_keys' => $removed_local_changes, + 'invalid_language_file_keys' => $invalid_language_file, + 'not_installed_language_keys' => $not_installed, + ]; + } + + public function testUninstallChangesObjectEmbedsTheThrowableMessageFromAnErrorResultIntoTheFailureMessage(): void + { + // the error path returns before ilObjLanguage::refreshPlugins(); only the "lang" logger stub is needed + $exception_message = 'boom'; + $this->stubLoggerLangError($exception_message); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException($exception_message)) + ); + + // wrapped as "{error}
action_aborted", like uninstallObject() + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + public function testUninstallChangesObjectShowsTheNoPermissionMessageForA403ErrorResult(): void + { + $error_string = 'msg_no_perm_write'; + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultError(new \DomainException('internal wording', 403)) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', $this->stringContains($error_string), true); + + $ctrl = $this->createMock(ilCtrl::class); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDoesNotContinueToTheSuccessPath(): void + { + // the error path returns before ilObjLanguage::refreshPlugins(); only the "lang" logger stub is needed + $this->stubLoggerLangError('boom'); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException('boom')) + ); + + // a dropped `return` after the redirect would reach `$result->value()` and the unstubbed refreshPlugins() + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage'); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + /** $ids needs a real database to resolve, so only empty $ids is used; the mock returns the error the real Activity would. */ + public function testUninstallChangesObjectPassesOnlyLanguageKeysWithoutAModeKeyToMaybePerformAs(): void + { + $this->stubLoggerLangError('At least one language key is required.', \InvalidArgumentException::class); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_keys' => []]) + ->willReturn(new ResultError( + new \InvalidArgumentException('At least one language key is required.') + )); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('failure', $this->anything(), true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + public function testUninstallChangesObjectSetsSuccessMessageWhenOnlyRemovedLocalChangesLanguageKeysIsNonEmpty(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultOk($this->removeLocalChangesPerformResult(['de'], [], [])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('success', 'selected_languages_updated
meta_l_de', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + public function testUninstallChangesObjectSetsFailureMessageWhenOnlyInvalidLanguageFileKeysIsNonEmpty(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultOk($this->removeLocalChangesPerformResult([], ['fr'], [])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'meta_l_fr: file_not_valid', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + public function testUninstallChangesObjectSetsInfoMessageWhenOnlyNotInstalledLanguageKeysIsNonEmpty(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultOk($this->removeLocalChangesPerformResult([], [], ['it'])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('info', 'meta_l_it language_not_installed', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + /** The three buckets are different message types, so all three setOnScreenMessage() calls must happen. */ + public function testUninstallChangesObjectSetsAllThreeMessagesWhenAllBucketsAreNonEmpty(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn(new ResultOk( + $this->removeLocalChangesPerformResult(['de'], ['fr'], ['it']) + )); + + $captured_calls = []; + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->exactly(3)) + ->method('setOnScreenMessage') + ->willReturnCallback(function (string $type, string $message, bool $keep) use (&$captured_calls): void { + $captured_calls[] = [$type, $message]; + }); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + + self::assertCount(3, $captured_calls); + self::assertSame(['success', 'selected_languages_updated
meta_l_de'], $captured_calls[0]); + self::assertSame(['failure', 'meta_l_fr: file_not_valid'], $captured_calls[1]); + self::assertSame(['info', 'meta_l_it language_not_installed'], $captured_calls[2]); + } + + /** With every bucket empty no message appears; unlike the legacy code, which always showed "selected_languages_updated". */ + public function testUninstallChangesObjectSetsNoMessageWhenAllBucketsAreEmpty(): void + { + $this->stubComponentRepositoryWithNoPlugins(); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->method('maybePerformAs')->willReturn( + new ResultOk($this->removeLocalChangesPerformResult([], [], [])) + ); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->never())->method('setOnScreenMessage'); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject([]); + } + + private function invokeProtectedMethod(object $object, string $method_name, array $args = []): mixed + { + // invoke() bypasses visibility, setAccessible() is deprecated since PHP 8.1 + return (new ReflectionMethod($object, $method_name))->invoke($object, ...$args); + } + + private function createGuiWithSetLanguageDetectionEnabledCollaborators( + SetLanguageDetectionEnabled $set_language_detection_enabled, + ilGlobalTemplateInterface $tpl, + ilCtrl $ctrl, + ilLanguage $lng + ): ilObjLanguageFolderGUI { + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $this->setProperty($gui, 'set_language_detection_enabled', $set_language_detection_enabled); + $this->setProperty($gui, 'current_user_id', 6); + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $lng); + $this->setProperty($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + + return $gui; + } + + public function testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledTrue(): void + { + // the Error's \Throwable goes through activityErrorMessage(), which logs via the "lang" logger: needs the stub + $this->stubLoggerLangError('boom'); + + $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); + $set_language_detection_enabled->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['enabled' => true]) + ->willReturn(new ResultError(new \RuntimeException('boom'))); + + $gui = $this->createGuiWithSetLanguageDetectionEnabledCollaborators( + $set_language_detection_enabled, + $this->createMock(ilGlobalTemplateInterface::class), + $this->createMock(ilCtrl::class), + $this->createLanguageMockReturningTopicAsIs() + ); + + $this->invokeProtectedMethod($gui, 'enableLanguageDetectionObject'); + } + + public function testDisableLanguageDetectionObjectCallsMaybePerformAsWithEnabledFalse(): void + { + // logger stub needed for the same reason as above + $this->stubLoggerLangError('boom'); + + $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); + $set_language_detection_enabled->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['enabled' => false]) + ->willReturn(new ResultError(new \RuntimeException('boom'))); + + $gui = $this->createGuiWithSetLanguageDetectionEnabledCollaborators( + $set_language_detection_enabled, + $this->createMock(ilGlobalTemplateInterface::class), + $this->createMock(ilCtrl::class), + $this->createLanguageMockReturningTopicAsIs() + ); + + $this->invokeProtectedMethod($gui, 'disableLanguageDetectionObject'); + } + + public function testSetLanguageDetectionEnabledObjectEmbedsTheThrowableMessageAndRedirectsOnError(): void + { + $exception_message = 'no write permission'; + $this->stubLoggerLangError($exception_message); + + $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); + $set_language_detection_enabled->method('maybePerformAs')->willReturn( + new ResultError(new \RuntimeException($exception_message)) + ); + + // wrapped as "{error}
action_aborted", like uninstallObject() + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithSetLanguageDetectionEnabledCollaborators( + $set_language_detection_enabled, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + // a dropped `return` after the redirect would run the success path into un-stubbed legacy collaborators + $this->invokeProtectedMethod($gui, 'enableLanguageDetectionObject'); + } + + /** Unlike the four write commands, enable/disableLanguageDetectionObject() do not call checkPermission(); only the Activity enforces it. */ + public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission(): void + { + /** @var ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject $gui */ + $gui = $this->getMockBuilder(ilObjLanguageFolderGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['checkPermission']) + ->getMock(); + $gui->expects($this->never())->method('checkPermission'); + + $this->setProperty($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); + $this->setProperty($gui, 'tpl', $this->createMock(ilGlobalTemplateInterface::class)); + $this->setProperty($gui, 'ctrl', $this->createMock(ilCtrl::class)); + $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'current_user_id', 6); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'activity_error_logger', + $this->activityErrorLoggerForGui() + ); + + $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); + // denial is simulated at Activity level so the success path (viewObject()) is not taken + $set_language_detection_enabled->method('maybePerformAs')->willReturn( + new ResultError(new \DomainException('no write permission', 403)) + ); + $this->setReadonlyPropertyDeclaredOnGuiClass( + $gui, + 'set_language_detection_enabled', + $set_language_detection_enabled + ); + + $this->invokeProtectedMethod($gui, 'enableLanguageDetectionObject'); + $this->invokeProtectedMethod($gui, 'disableLanguageDetectionObject'); + } + + /** + * Stubs `global $DIC["ilObjDataCache"]` for ilObject::_lookupType()/_lookupTitle(). Reuses an existing + * $GLOBALS['DIC'] so it can coexist with stubComponentRepositoryWithNoPlugins(). + * + * @param array $types_by_id obj_id => type; a missing id resolves to "" like the real ilObjectDataCache + * @param array $titles_by_id obj_id => title; lookupTitle() must never be called when $expect_no_title_lookup is true + */ + private function stubObjDataCache( + array $types_by_id, + array $titles_by_id = [], + bool $expect_no_title_lookup = false + ): void { + $cache = $this->createMock(\ilObjectDataCache::class); + $cache->method('lookupType')->willReturnCallback( + static fn(int $id): string => $types_by_id[$id] ?? '' + ); + if ($expect_no_title_lookup) { + $cache->expects($this->never())->method('lookupTitle'); + } else { + $cache->method('lookupTitle')->willReturnCallback( + static fn(int $id): string => $titles_by_id[$id] ?? '' + ); + } + + if (!isset($GLOBALS['DIC'])) { + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + } + $GLOBALS['DIC']['ilObjDataCache'] = $cache; + } + + /** A single non-"lng" id aborts the whole request ('obj_not_found'+'action_aborted', redirect) without calling the Activity. */ + public function testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(): void + { + $this->stubObjDataCache([5 => 'cat'], [], true); + + $install_language = $this->createMock(InstallLanguage::class); + $install_language->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'obj_not_found
action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithCollaborators( + $install_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); + } + + /** For all-"lng" ids the Activity is still called, with 'language_keys' from ilObject::_lookupTitle(). */ + public function testInstallObjectWithAllLanguageObjectIdsStillCallsInstallLanguageWithResolvedLanguageKeys(): void + { + $this->stubObjDataCache([5 => 'lng'], [5 => 'de']); + + $install_language = $this->createMock(InstallLanguage::class); + $install_language->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_keys' => ['de'], 'mode' => InstallLanguage::MODE_INSTALL]) + ->willReturn(new ResultOk($this->emptyPerformResult())); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithCollaborators( + $install_language, + $this->createMock(ilGlobalTemplateInterface::class), + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); + } + + /** uninstallObject() variant. */ + public function testUninstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUninstallLanguage(): void + { + $this->stubObjDataCache([5 => 'usr'], [], true); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'obj_not_found
action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject(['5']); + } + + /** uninstallObject() with all-"lng" ids is unchanged. */ + public function testUninstallObjectWithAllLanguageObjectIdsStillCallsUninstallLanguageWithResolvedLanguageKeys(): void + { + $this->stubObjDataCache([5 => 'lng'], [5 => 'de']); + + $uninstall_language = $this->createMock(UninstallLanguage::class); + $uninstall_language->expects($this->once()) + ->method('maybePerformAs') + ->with($this->anything(), 6, ['language_keys' => ['de']]) + ->willReturn(new ResultOk($this->uninstallPerformResult([], [], [], []))); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect'); + + $gui = $this->createGuiWithUninstallLanguageCollaborators( + $uninstall_language, + $this->createMock(ilGlobalTemplateInterface::class), + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallObject(['5']); + } + + /** uninstallChangesObject() variant. */ + public function testUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsRemoveLocalLanguageChanges(): void + { + $this->stubObjDataCache([5 => 'cat'], [], true); + + $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); + $remove_local_language_changes->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'obj_not_found
action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithRemoveLocalLanguageChangesCollaborators( + $remove_local_language_changes, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->uninstallChangesObject(['5']); + } + + /** refreshSelectedObject() variant. */ + public function testRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUpdateLanguage(): void + { + $this->stubObjDataCache([5 => 'cat'], [], true); + + $update_language = $this->createMock(UpdateLanguage::class); + $update_language->expects($this->never())->method('maybePerformAs'); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'obj_not_found
action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + $gui = $this->createGuiWithUpdateLanguageCollaborators( + $update_language, + $tpl, + $ctrl, + $this->createLanguageMockReturningTopicAsIs() + ); + + $gui->refreshSelectedObject(['5']); + } + + /** + * Stub for buildConfirmModal()'s abort branch; returns the Interruptive that chain produces, so a test can + * assert the abort branch's `return` was taken. + * + * @return array{0: \ILIAS\UI\Factory&\PHPUnit\Framework\MockObject\MockObject, 1: \ILIAS\UI\Implementation\Component\Modal\Interruptive} + */ + private function createStubModalUiFactory(): array + { + // buildConfirmModal() returns the concrete Interruptive class, so the interface alone fails the return type + $interruptive = $this->createMock(\ILIAS\UI\Implementation\Component\Modal\Interruptive::class); + $interruptive->method('withActionButtonLabel')->willReturnSelf(); + + $modal_factory = $this->createMock(\ILIAS\UI\Component\Modal\Factory::class); + $modal_factory->method('interruptive')->willReturn($interruptive); + + $ui_factory = $this->createMock(\ILIAS\UI\Factory::class); + $ui_factory->method('modal')->willReturn($modal_factory); + + return [$ui_factory, $interruptive]; + } + + /** buildConfirmModal() variant: the abort branch returns its own Interruptive, so the per-id loop was never reached. */ + public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNeverLeaksData(): void + { + $this->stubObjDataCache([5 => 'cat'], [], true); + + /** @var ilObjLanguageFolderGUI $gui */ + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once()) + ->method('setOnScreenMessage') + ->with('failure', 'obj_not_found
action_aborted', true); + + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with( + $this->isInstanceOf(ilObjLanguageFolderGUI::class), + 'view' + ); + + [$ui_factory, $expected_interruptive] = $this->createStubModalUiFactory(); + + $this->setProperty($gui, 'tpl', $tpl); + $this->setProperty($gui, 'ctrl', $ctrl); + $this->setProperty($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); + $this->setProperty($gui, 'ui_factory', $ui_factory); + + $result = $this->invokeProtectedMethod($gui, 'buildConfirmModal', [ + ['5'], + 'refresh_languages', + 'confirmRefresh', + 'lang_refresh_confirm_selected', + 'lang_refresh_confirm_info', + ]); + + $this->assertSame($expected_interruptive, $result); + } + + public function testOnlyTheStateChangingCommandsAreUnsafeGetCommandsSoTheCsrfTokenIsRequired(): void + { + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + $this->assertInstanceOf(ilCtrlSecurityInterface::class, $gui); + + $this->assertEqualsCanonicalizing( + ['executeTableAction', 'enableLanguageDetection', 'disableLanguageDetection'], + $gui->getUnsafeGetCommands() + ); + $this->assertSame([], $gui->getSafePostCommands()); + } + + /** + * @param list $methods + * @return ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject + */ + private function createDispatchGui(string $cmd, ?string $table_action, array $methods): ilObjLanguageFolderGUI + { + $gui = $this->getMockBuilder(ilObjLanguageFolderGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(array_merge(['checkPermission', 'prepareOutput'], $methods)) + ->getMock(); + + $request_wrapper = $this->createStub(\ILIAS\HTTP\Wrapper\RequestWrapper::class); + $request_wrapper->method('has')->willReturn($table_action !== null); + $request_wrapper->method('retrieve')->willReturn($table_action); + $ctrl = $this->createStub(ilCtrl::class); + $ctrl->method('getNextClass')->willReturn(''); + $ctrl->method('getCmd')->willReturn($cmd); + $lng = $this->createStub(ilLanguage::class); + + foreach ([ + 'action_token' => new \ILIAS\UI\URLBuilderToken(['language_folder'], 'action'), + 'request_wrapper' => $request_wrapper, + 'refinery' => $this->createStub(\ILIAS\Refinery\Factory::class), + 'ctrl' => $ctrl, + 'lng' => $lng, + 'ui_renderer' => $this->createStub(\ILIAS\UI\Renderer::class), + 'tabs_gui' => $this->createStub(ilTabsGUI::class), + 'type' => 'lngf', + 'ref_id' => 1, + ] as $name => $value) { + (new ReflectionProperty(ilObjLanguageFolderGUI::class, $name))->setValue($gui, $value); + } + + return $gui; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('commandsThatMustFallBackToView')] + public function testExecuteCommandOnlyShowsTheViewForCommandsOutsideTheExactAllowlist(string $cmd): void + { + $gui = $this->createDispatchGui( + $cmd, + 'checkLanguage', + ['viewObject', 'executeTableActionObject', 'enableLanguageDetectionObject', 'saveObject', 'checkLanguageObject'] + ); + $gui->expects($this->once())->method('viewObject'); + $gui->expects($this->never())->method('executeTableActionObject'); + $gui->expects($this->never())->method('enableLanguageDetectionObject'); + $gui->expects($this->never())->method('saveObject'); + $gui->expects($this->never())->method('checkLanguageObject'); + + $gui->executeCommand(); + } + + /** @return array */ + public static function commandsThatMustFallBackToView(): array + { + return [ + 'upper case first letter' => ['ExecuteTableAction'], + 'lower case' => ['executetableaction'], + 'detection with upper case' => ['EnableLanguageDetection'], + 'inherited save' => ['save'], + 'empty' => [''], + ]; + } + + public function testExecuteCommandDispatchesTheExactTableActionCommandToTheTableAction(): void + { + $gui = $this->createDispatchGui('executeTableAction', null, ['viewObject', 'executeTableActionObject']); + $gui->expects($this->once())->method('executeTableActionObject'); + $gui->expects($this->never())->method('viewObject'); + + $gui->executeCommand(); + } + + public function testTableActionCheckLanguageIsDispatchedToCheckLanguageObject(): void + { + $gui = $this->createDispatchGui('executeTableAction', 'checkLanguage', ['viewObject', 'checkLanguageObject']); + $gui->expects($this->once())->method('checkLanguageObject'); + + $this->invokeTableAction($gui); + } + + public function testUnknownTableActionOnlyShowsTheView(): void + { + $gui = $this->createDispatchGui('executeTableAction', 'doesNotExist', ['viewObject', 'checkLanguageObject']); + $gui->expects($this->once())->method('viewObject'); + $gui->expects($this->never())->method('checkLanguageObject'); + + $this->invokeTableAction($gui); + } + + private function invokeTableAction(ilObjLanguageFolderGUI $gui): void + { + (new ReflectionMethod(ilObjLanguageFolderGUI::class, 'executeTableActionObject'))->invoke($gui); + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageTest.php b/components/ILIAS/Language/tests/ilObjLanguageTest.php new file mode 100644 index 000000000000..329c09fe57c1 --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageTest.php @@ -0,0 +1,51 @@ +getFileName(); + $source = file_get_contents($file); + + self::assertStringContainsString( + '$this->absolute_path = (string) realpath(__DIR__ . "/../../../../");', + $source, + 'ilObjLanguage::__construct() no longer builds absolute_path with the ' . + 'expected four "../" segments - update this test if the file moved, ' . + 'but otherwise this is the off-by-one regression this test guards against.' + ); + + $computed_root = realpath(dirname($file) . '/../../../../'); + self::assertNotFalse($computed_root, 'The computed absolute_path must resolve to a real directory.'); + self::assertFileExists( + $computed_root . '/lang/ilias_en.lang', + 'absolute_path must resolve to the ILIAS root, where lang/ilias_xx.lang files ' . + 'live - otherwise check()/refresh()/removeLocalChanges() will report every ' . + 'language as invalid.' + ); + } +} diff --git a/components/ILIAS/Language/tests/ilSetupLanguageTest.php b/components/ILIAS/Language/tests/ilSetupLanguageTest.php index 47465eb3043d..5cebd8b20476 100755 --- a/components/ILIAS/Language/tests/ilSetupLanguageTest.php +++ b/components/ILIAS/Language/tests/ilSetupLanguageTest.php @@ -18,22 +18,25 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use PHPUnit\Framework\Attributes\DataProvider; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; + /** * Class ilSetupLanguageTest - * @author Sílvia Mariné */ class ilSetupLanguageTest extends ilLanguageBaseTestCase { private ilSetupLanguage $newLangSetupDe; private ilSetupLanguage $newLangSetupEs; - - /** - * @var \ILIAS\Language\Setup\Language[] - */ private array $langInstalled; + private string $tempComponentDir = 'artifacts/test_lang_component'; protected function setUp(): void { + unset($GLOBALS['ilDB']); $this->newLangSetupDe = new ilSetupLanguage('de'); $this->newLangSetupEs = new ilSetupLanguage('es'); @@ -41,6 +44,88 @@ protected function setUp(): void $this->langInstalled[] = $this->newLangSetupEs; } + protected function tearDown(): void + { + $this->cleanupTempDir(); + parent::tearDown(); + } + + private function cleanupTempDir(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + if (is_dir($fullPath)) { + $files = glob($fullPath . '/*'); + if ($files !== false) { + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + rmdir($fullPath); + } + } + + private function createMockComponentDirectory( + string $path = 'artifacts/test_lang_component', + string $prefix = 'test' + ): LanguageFileDirectory { + return new class ($path, $prefix) implements LanguageFileDirectory { + public function __construct(private string $path, private string $prefix) + { + } + + 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 createMockCustomizingDirectory(): LanguageFileDirectory + { + return new class ($this->tempComponentDir) implements LanguageFileDirectory { + public function __construct(private string $path) + { + } + + public function getPrefix(): string + { + return ''; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return '.local'; + } + + public function isLocal(): bool + { + return true; + } + }; + } + public function testRetrieveLanguageKey(): void { $this->assertEquals('de', $this->newLangSetupDe->getLangKey()); @@ -57,4 +142,303 @@ public function testRetrieveInstalledLanguage(): void $this->assertContains('de', $languagesAsKeys); $this->assertContains('es', $languagesAsKeys); } + + private function emptyDatabase(bool $expect_query): ilDBInterface&\PHPUnit\Framework\MockObject\MockObject + { + $db = $this->createMock(ilDBInterface::class); + $db->method('quote')->willReturn("'x'"); + $db->method('like')->willReturn('1=1'); + $db->method('fetchObject')->willReturn(null); + $db->expects($expect_query ? self::once() : self::never())->method('query') + ->willReturn($this->createStub(ilDBStatement::class)); + + return $db; + } + + /** + * @return array handler, constructor, global, winner + */ + public static function databaseSources(): array + { + return [ + 'handler beats constructor and global' => [true, true, true, 'handler'], + 'handler beats constructor' => [true, true, false, 'handler'], + 'handler beats global' => [true, false, true, 'handler'], + 'constructor beats global' => [false, true, true, 'constructor'], + 'only constructor' => [false, true, false, 'constructor'], + 'only global' => [false, false, true, 'global'], + ]; + } + + #[DataProvider('databaseSources')] + public function testDatabaseSourcePrecedence(bool $handler, bool $constructor, bool $global, string $winner): void + { + $dbs = []; + foreach (['handler' => $handler, 'constructor' => $constructor, 'global' => $global] as $name => $present) { + if ($present) { + $dbs[$name] = $this->emptyDatabase($name === $winner); + } + } + if (isset($dbs['global'])) { + $GLOBALS['ilDB'] = $dbs['global']; + } + + $setup_language = new ilSetupLanguage('en', null, $dbs['constructor'] ?? null); + if (isset($dbs['handler'])) { + $setup_language->setDbHandler($dbs['handler']); + } + + self::assertSame([], $setup_language->getInstalledLanguages()); + } + + public function testFileBasedMethodsWorkWithoutAnyDatabase(): void + { + $setup_language = new ilSetupLanguage('en'); + + self::assertContainsOnlyString($setup_language->getLocalLanguages()); + self::assertTrue($setup_language->checkLanguageForInstallation('en')); + } + + /** + * @return array + */ + public static function databaseMethods(): array + { + return [ + 'repository' => [static fn(ilSetupLanguage $l): array => $l->getInstalledLanguages()], + 'manager' => [static fn(ilSetupLanguage $l) => $l->insertLanguageForInstallation('en')], + ]; + } + + #[DataProvider('databaseMethods')] + public function testDatabaseMethodsWithoutDatabaseThrowLogicException(Closure $call): void + { + $this->expectException(LogicException::class); + + $call(new ilSetupLanguage('en')); + } + + private function callCheckLanguage(ilSetupLanguage $setup_language, string $lang_key): bool + { + $reflection = new ReflectionMethod($setup_language, 'checkLanguage'); + return $reflection->invoke($setup_language, $lang_key); + } + + public function testCheckLanguageWithOnlyMainFile(): void + { + $mockDir = $this->createMockComponentDirectory(); + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $mockDir + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertTrue($this->callCheckLanguage($setup_language, 'en')); + $this->assertTrue($this->callCheckLanguage($setup_language, 'de')); + } + + public function testCheckLanguageWithBothMainAndComponentFiles(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + if (!is_dir($fullPath)) { + mkdir($fullPath, 0777, true); + } + + try { + file_put_contents( + $fullPath . '/ilias_en.lang', + "\ntest_key#:#test_value\n" + ); + + $mockDir = $this->createMockComponentDirectory($this->tempComponentDir, 'test'); + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $mockDir + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertTrue($this->callCheckLanguage($setup_language, 'en')); + } finally { + $this->cleanupTempDir(); + } + } + + public function testCheckLanguageWithMissingMainFile(): void + { + $mockDir = $this->createMockComponentDirectory(); + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $mockDir + ); + $setup_language = new ilSetupLanguage('xx', $manager); + + $this->assertFalse($this->callCheckLanguage($setup_language, 'xx')); + } + + public function testCheckLanguageFailsOnInvalidComponentLanguageFile(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + if (!is_dir($fullPath)) { + mkdir($fullPath, 0777, true); + } + + try { + file_put_contents($fullPath . '/ilias_en.lang', "invalid content without header\n"); + + $mockDir = $this->createMockComponentDirectory($this->tempComponentDir, 'test'); + $manager = new LanguageFileDirectoryManager( + new MainLanguageFileDirectory(), + $mockDir + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertFalse($this->callCheckLanguage($setup_language, 'en')); + + file_put_contents( + $fullPath . '/ilias_en.lang', + "\nonly_one_part\n" + ); + $this->assertFalse($this->callCheckLanguage($setup_language, 'en')); + } finally { + $this->cleanupTempDir(); + } + } + + public function testCheckLanguageFailsOnNonUtf8Value(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + if (!is_dir($fullPath)) { + mkdir($fullPath, 0777, true); + } + + try { + file_put_contents( + $fullPath . '/ilias_en.lang', + "\n" . + "test_key#:#" . chr(0xFF) . chr(0xFE) . "\n" + ); + + $mockDir = $this->createMockComponentDirectory($this->tempComponentDir, 'test'); + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $mockDir + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertFalse($this->callCheckLanguage($setup_language, 'en')); + } finally { + $this->cleanupTempDir(); + } + } + + public function testGetInvalidLocalLanguageFilesReturnsEmptyForValidFile(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + mkdir($fullPath, 0777, true); + file_put_contents($fullPath . '/ilias_en.lang.local', 'custom'); + + $manager = new LanguageFileDirectoryManager( + $this->createMockCustomizingDirectory(), + new MainLanguageFileDirectory() + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertSame([], $setup_language->getInvalidLocalLanguageFiles(['en'])); + } + + public function testGetInvalidLocalLanguageFilesIgnoresMissingFile(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + mkdir($fullPath, 0777, true); + + $manager = new LanguageFileDirectoryManager( + $this->createMockCustomizingDirectory(), + new MainLanguageFileDirectory() + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertSame([], $setup_language->getInvalidLocalLanguageFiles(['en'])); + } + + public function testGetInvalidLocalLanguageFilesReturnsMalformedFileName(): void + { + $fullPath = __DIR__ . '/../../../../' . $this->tempComponentDir; + mkdir($fullPath, 0777, true); + file_put_contents($fullPath . '/ilias_en.lang.locl', 'custom'); + + $manager = new LanguageFileDirectoryManager( + $this->createMockCustomizingDirectory(), + new MainLanguageFileDirectory() + ); + $setup_language = new ilSetupLanguage('en', $manager); + + $this->assertSame( + ['ilias_en.lang.locl'], + $setup_language->getInvalidLocalLanguageFiles(['en']) + ); + } + + private function createDatabaseMock(): ilDBInterface + { + $db = $this->createMock(ilDBInterface::class); + $db->method('nextId')->willReturn(42); + $db->method('quote')->willReturnCallback( + static fn(mixed $value): string => "'" . (string) $value . "'" + ); + $db->method('now')->willReturn('NOW()'); + $db->method('manipulate')->willReturn(1); + + return $db; + } + + public function testRegisterInstalledLanguageInsertsUnknownLanguage(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->logicalAnd( + $this->stringContains('INSERT INTO object_data'), + $this->stringContains("'de'"), + $this->stringContains("'installed'") + )) + ->willReturn(1); + + $this->newLangSetupDe->setDbHandler($db); + $this->newLangSetupDe->registerInstalledLanguage('de', [], []); + } + + public function testRegisterInstalledLanguageInsertsAsInstalledLocalWhenLocalFileIsPresent(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->stringContains("'installed_local'")); + + $this->newLangSetupDe->setDbHandler($db); + $this->newLangSetupDe->registerInstalledLanguage('de', [], ['de']); + } + + public function testRegisterInstalledLanguageUpdatesKnownLanguage(): void + { + $db = $this->createDatabaseMock(); + $db->expects($this->once()) + ->method('manipulate') + ->with($this->logicalAnd( + $this->stringContains('UPDATE object_data'), + $this->stringContains("'installed'"), + $this->stringContains('obj_id = ' . "'7'") + )) + ->willReturn(1); + + $this->newLangSetupDe->setDbHandler($db); + $this->newLangSetupDe->registerInstalledLanguage( + 'de', + ['de' => ['obj_id' => 7, 'status' => 'not_installed']], + [] + ); + } } diff --git a/docs/development/language.md b/docs/development/language.md index 82a792ef9404..54f0009393a6 100755 --- a/docs/development/language.md +++ b/docs/development/language.md @@ -71,14 +71,19 @@ Only a defined set of HTML tags are allowed to be used within the `text_content` All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes`. -## 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()`. - - $lng->loadLanguageModule("frm"); - $tpl->setVariable("TEXT", $lng->txt("frm_new_posting")); +## Using the Language Service +New component-revision code should depend on `ILIAS\Language\Language`. During the legacy bootstrap this interface is +provided through a lazy bridge and delegates to the active `ilLanguage` runtime instance after the legacy container has +been initialised. + +Legacy components may continue to use `$DIC['lng']` or `$DIC->language()` during the transition. These access paths +are compatibility provisions for non-migrated code and are not the preferred API for new components. + +Setup code uses the separate `ilSetupLanguage` implementation. It is intentionally independent of runtime user, +session, and container state and must not be selected for normal runtime initialisation. + + $language->loadLanguageModule("frm"); + $tpl->setVariable("TEXT", $language->txt("frm_new_posting")); # Roles The language handling process in ILIAS knows four distinct roles: diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index b7f180498693..4faef0d73bd7 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -9868,90 +9868,6 @@ export#:#export_resource#:#Ressource export#:#export_save_selection#:#Export starten export#:#export_select_resources#:#Objekte auswählen export#:#no_file#:#Keine Datei -file#:#add_icon#:#Icon hinzufügen -file#:#amount_of_downloads#:#Downloads -file#:#amount_of_downloads_since#:#%d seit %s -file#:#copyright_custom#:#Benutzerdefiniert -file#:#copyright_custom_info#:#Lizenz auswählen, welche auf alle entpackten Dateien dieses Archivs angewandt wird. -file#:#copyright_inherited#:#Vererbt -file#:#copyright_inherited_info#:#Lizenz des Zip-Archivs auf seine entpackten Dateien anwenden.
Lizenz des Zip-Archivs: %s. -file#:#could_not_create_file_objs#:#Beim Erstellen der Dateiobjekte ist ein Fehler aufgetreten. Bitte wenden Sie sich an die technische Betreuung dieser Installation. -file#:#de_activate_icon#:#(De-)Aktivieren -file#:#download_ascii_filename#:#Nur ASCII-Zeichen im Download -file#:#download_ascii_filename_info#:#Die Namen heruntergeladener Dateien enthalten nur ASCII-Zeichen und können so auf allen Systemen problemlos genutzt werden. Andere Zeichen werden durch Unterstriche ersetzt. Auf Installationen, die Dateinamen mit nicht-lateinischen Schriftzeichen nutzen, muss diese Option deaktiviert werden. -file#:#file_action_download#:#Datei herunterladen oder Inhalt anzeigen -file#:#file_action_download_info#:#Wenn WOPI aktiviert ist und die Berechtigung „Inhalt anzeigen“ erteilt wurde, wird die Datei auf der Registerkarte „Inhalt“ angezeigt. Wenn nicht, wird die Datei zum direkten Download angeboten. -file#:#file_action_show#:#Zunächst Reiter „Info“ anzeigen -file#:#file_action_show_info#:#Der Reiter „Info“ wird angezeigt, wo die Datei in einem zweiten Schritt heruntergeladen werden kann. -file#:#file_btn_lp_toggle_state_completed#:#Auf „Nicht bearbeitet“ setzen -file#:#file_btn_lp_toggle_state_not_completed#:#Auf „Bearbeitet“ setzen -file#:#file_copy#:#Datei kopieren -file#:#file_download#:#Datei herunterladen -file#:#file_import#:#Dateiimport -file#:#file_new_version#:#Neue Version anlegen -file#:#file_new_version_info#:#Eine neue Dateiversion anlegen. Alte Versionen bleiben erhalten. -file#:#file_publish#:#Entwurf veröffentlichen -file#:#file_rollback_rollback_first#:#Die Version konnte nicht zurückgesetzt werden, weil ein unveröffentlichter Entwurf existiert. -file#:#file_rollback_same_version#:#Dies ist bereits die veröffentlichte Version -file#:#file_unpublish#:#Als Entwurf markieren -file#:#file_upload_info_file_with_critical_extension#:#Mindestens eine Datei enthält eine kritische oder unbekannte Dateiendung. Die Dateiendung wird bei jedem Download zu .sec geändert. Bitte kontaktieren Sie gegebenenfalls Ihre technische Betreuung. Dateiname(n): %s -file#:#file_uploaded_by#:#Hochgeladen von -file#:#file_version_draft#:#Entwurf -file#:#file_version_draft_info#:#Die aktuellste Version ist im Status „Entwurf“. Solange diese Version nicht veröffentlicht wurde, können keine neuen Versionen angelegt werden. Personen mit Leserecht auf die Datei erhalten die aktuell veröffentlichte Version. -file#:#form_icon_creation#:#Icon erstellen -file#:#form_icon_updating#:#Icon aktualisieren -file#:#general_upload_error_occured#:#Beim Verarbeiten der Datei ist ein unerwartetet Fehler aufgetreten. -file#:#important_info#:#Wichtige Informationen -file#:#important_info_byline#:#Die Angaben werden im Reiter „Info“ angezeigt. -file#:#input_active#:#Aktiv -file#:#input_desc_active#:#Dieses Icon aktivieren. -file#:#input_desc_icon#:#Bild welches als Icon für Dateien mit den spezifizierten Suffixen verwendet werden soll. -file#:#input_desc_suffixes#:#Liste Komma-separierter Suffixe (Nur Buchstaben und Zahlen ohne vorangehenden Punkt). -file#:#input_icon#:#Icon -file#:#input_suffixes#:#Suffix(e) -file#:#migrated#:#Status -file#:#mime_type#:#MIME-Type -file#:#msg_cant_unpublish#:#Aktion konnte nicht ausgeführt werden -file#:#msg_confirm_entry_deletion#:#Wollen Sie den nachfolgenden Eintrag wirklich löschen?: -file#:#msg_error_active_suffixes_blacklisted#:#Eine der gewählten Dateiendungen ist auf der globalen Blacklist und kann derzeit nicht genutzt werden. -file#:#msg_error_active_suffixes_conflict#:#Fehler: Es dürfen nicht mehrere Icons für ein Suffix aktiv sein. Bitte deaktivieren Sie entweder dieses Icon oder das andere aktivierte Icon, dessen Suffixe sich mit jenen dieses Icons überschneiden. -file#:#msg_error_active_suffixes_not_whitelisted#:#Die gewählte Dateiendung ist nicht auf der globalen Whitelist, beim Download werden die Dateine zu .sec. umbenannt. -file#:#msg_error_duplicate_suffix_entries#:#Fehler: Die Komma-separierte Suffix-Liste enthält Duplikate. -file#:#msg_error_icon_deletion#:#Fehler: Icon Löschung fehlgeschlagen. -file#:#msg_error_suffixes_with_forbidden_characters#:#Fehler: unerlaubte Zeichen. Nur Buchstaben, Nummern, Leerzeichen und Kommas sind erlaubt. -file#:#msg_icon_missing_from_db#:#Icon ist nicht in der Datenbank. -file#:#msg_icon_missing_from_irss#:#Icon ist nicht im Resource Storage. -file#:#msg_success_icon_activated#:#Icon erfolgreich aktiviert. -file#:#msg_success_icon_created#:#Icon erfolgreich erstellt. -file#:#msg_success_icon_deactivated#:#Icon erfolgreich deaktiviert. -file#:#msg_success_icon_deletion#:#Icon erfolgreich gelöscht. -file#:#msg_success_icon_updated#:#Icon erfolgreich aktualisiert. -file#:#msg_unzip_success#:#Archiv wurde erfolreich entpackt. -file#:#on_click_action#:#Klick auf Titel -file#:#preview_caption#:#Vorschau %svon %s -file#:#preview_image_size_info#:#Die Vorschauversionen der Bilder werden je nach Bedarf verkleinert oder vergrößert, so dass ihre längste Seite der hier eingegebenen Länge (in px) entspricht. -file#:#preview_persisting#:#Vorschaubilder speichern -file#:#preview_persisting_info#:#Vorschaubilder werden generiert und gespeichert. Sie müssen nicht mehr bei jedem Aufruf neu erzeugt werden. Das verbessert die Performanz. -file#:#previews_for_tiles#:#Kachelbilder -file#:#previews_for_tiles_info#:#Verwende Vorschaubilder für Kacheln. -file#:#publish_before_delete#:#Version(en) konnte(n) nicht gelöscht werden, da ein noch unveröffentlichter Entwurf existiert. -file#:#replace_file_info#:#Alle früheren Dateiversionen werden gelöscht. -file#:#resource_id#:#Ressourcen ID -file#:#service_settings#:#Weitere Funktionen -file#:#service_settings_saved#:#Gespeichert -file#:#set_license_for_all_files#:#Lizenz für alle Dateien festlegen -file#:#show_amount_of_downloads#:#Anzahl Downloads anzeigen -file#:#show_amount_of_downloads_info#:#Die Anzahl der Downloads wird im Reiter „Info“ des Dateiobjekts angezeigt. -file#:#storage_id#:#Storage ID -file#:#suffix_specific_icons#:#Suffix-spezifische Icons -file#:#suffixes#:#Suffix(e) -file#:#upload_files#:#Dateien hochladen -file#:#upload_files_limit#:#Die maximale Größe einer Datei beträgt %s. -file#:#upload_files_title#:#Dateien hochladen -file#:#upload_info#:#Datei -file#:#upload_info_desc#:#Sie können die Dateien und die Dateiversionen im Tab "Versionen" verwalten. -file#:#version_uploaded#:#Version hochgeladen am -file#:#versionname#:#Angezeigter Titel fils#:#add_upload_policy#:#Richtlinie hinzufügen fils#:#edit_upload_policy#:#Richtlinie bearbeiten fils#:#file_services#:#Datei-Services diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 556f3c605185..b1ce0563b563 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -9843,90 +9843,6 @@ export#:#export_resource#:#Resource export#:#export_save_selection#:#Start Export export#:#export_select_resources#:#Select Resources export#:#no_file#:#No file -file#:#add_icon#:#Add Icon -file#:#amount_of_downloads#:#Downloads -file#:#amount_of_downloads_since#:#%d times since %s -file#:#copyright_custom#:#Custom -file#:#copyright_custom_info#:#Choose a custom copyright which will be applied to all unzipped files of this archive. -file#:#copyright_inherited#:#Inherited -file#:#copyright_inherited_info#:#Apply the copyright of the zip archive to its unzipped files.
Copyright of zip archive: %s. -file#:#could_not_create_file_objs#:#An error occurred while creating your file objects. Please contact this installation's technical support. -file#:#de_activate_icon#:#Activate / Deactivate -file#:#download_ascii_filename#:#Allow Only ASCII Characters in Downloaded Filenames -file#:#download_ascii_filename_info#:#Downloaded files should only have ASCII-characters in their filename. Deactivate to use all characters. -file#:#file_action_download#:#Download file or view content -file#:#file_action_download_info#:#If WOPI is activated and the permission 'View Content' is given, the file is shown in tab 'Content'. If not, the file will be offered for direct download. -file#:#file_action_show#:#Redirect user to the file’s ‘Info’ tab -file#:#file_action_show_info#:#The 'Info' tab is shown where the file could be downloaded in a second step. -file#:#file_btn_lp_toggle_state_completed#:#Set Not Completed -file#:#file_btn_lp_toggle_state_not_completed#:#Set Completed -file#:#file_copy#:#Copy File -file#:#file_download#:#Download File -file#:#file_import#:#Import File -file#:#file_new_version#:#Create New Version -file#:#file_new_version_info#:#Create new file version. Previous versions will not be modified. -file#:#file_publish#:#Publish Draft -file#:#file_rollback_rollback_first#:#The selected version could not be published because an unpublished draft exists. -file#:#file_rollback_same_version#:#This is already the published version! -file#:#file_unpublish#:#Mark as Draft -file#:#file_upload_info_file_with_critical_extension#:#At least one uploaded file contains a critical or unknown file ending. Whenever the file is downloaded, its ending will be changed to ‘.sec’. If necessary, contact your administrator. Filename(s): %s -file#:#file_uploaded_by#:#Uploaded By -file#:#file_version_draft#:#Draft Version -file#:#file_version_draft_info#:#The latest version of this file has the status ‘Draft’. As long as this version has not been published, no new versions can be created. People with read permission for the file get the most recent previously published version. -file#:#form_icon_creation#:#Create Icon -file#:#form_icon_updating#:#Update Icon -file#:#general_upload_error_occured#:#An unexpected error occurred during upload. -file#:#important_info#:#Important Information -file#:#important_info_byline#:#The information will be displayed in the ‘Info’ tab. -file#:#input_active#:#Active -file#:#input_desc_active#:#Activate this icon. -file#:#input_desc_icon#:#Image to be used as the icon for files with the specified suffixes. -file#:#input_desc_suffixes#:#List of comma-separated suffixes (characters and numbers only, without preceding full stop). -file#:#input_icon#:#Icon -file#:#input_suffixes#:#Suffix(es) -file#:#migrated#:#Status -file#:#mime_type#:#MIME Type -file#:#msg_cant_unpublish#:#File could not be unpublished. -file#:#msg_confirm_entry_deletion#:#Are you sure you want to delete the following entry?: -file#:#msg_error_active_suffixes_blacklisted#:#One of the selected file extensions is on the global blacklist and cannot therefore be currently used. -file#:#msg_error_active_suffixes_conflict#:#Error: It is not possible to have multiple icons activated for the same suffix. Please deactivate either this icon or the other activated icon whose suffixes overlap with those of this icon. -file#:#msg_error_active_suffixes_not_whitelisted#:#The selected file extension is not on the global whitelist. The file suffixes will be changed to ‘.sec’ when downloaded. -file#:#msg_error_duplicate_suffix_entries#:#Error: the comma separated list of suffixes contains duplicate entries. -file#:#msg_error_icon_deletion#:#Error: icon deletion failed. -file#:#msg_error_suffixes_with_forbidden_characters#:#Error: forbidden characters. Only letters, numbers, spaces and commas are allowed. -file#:#msg_icon_missing_from_db#:#Icon missing from database. -file#:#msg_icon_missing_from_irss#:#Icon missing from resource storage. -file#:#msg_success_icon_activated#:#Icon successfully activated. -file#:#msg_success_icon_created#:#Icon successfully created. -file#:#msg_success_icon_deactivated#:#Icon successfully deactivated. -file#:#msg_success_icon_deletion#:#Icon successfully deleted. -file#:#msg_success_icon_updated#:#Icon successfully updated. -file#:#msg_unzip_success#:#Archive successfully unzipped. -file#:#on_click_action#:#Action When Title Clicked -file#:#preview_caption#:#Preview %sof %s -file#:#preview_image_size_info#:#The preview versions of images will be downscaled or upscaled as appropriate, so that their longest side is the length (in px) entered here. -file#:#preview_persisting#:#Persistent Preview Images -file#:#preview_persisting_info#:#Generated preview images will be stored by ILIAS and used from then on each time the preview icon for that file is clicked on. If deactivated, previews will be generated anew each time. -file#:#previews_for_tiles#:#Tiles -file#:#previews_for_tiles_info#:#Use Preview for Tiles where possible. -file#:#publish_before_delete#:#It was not possible to delete any of the existing versions because an unpublished draft exists. -file#:#replace_file_info#:#All previous file versions will be deleted. -file#:#resource_id#:#Resource ID -file#:#service_settings#:#Additional Features -file#:#service_settings_saved#:#Changes saved. -file#:#set_license_for_all_files#:#Set License for All Files -file#:#show_amount_of_downloads#:#Show Number of Downloads -file#:#show_amount_of_downloads_info#:#Display the number of times a file object has been downloaded on its 'Info' page. -file#:#storage_id#:#Storage ID -file#:#suffix_specific_icons#:#Suffix-Specific Icons -file#:#suffixes#:#Suffix(es) -file#:#upload_files#:#Upload Files -file#:#upload_files_limit#:#The maximum file size allowed is %s. -file#:#upload_files_title#:#Upload Files -file#:#upload_info#:#File -file#:#upload_info_desc#:#Uploads and versions can be managed in the ‘Versions’ tab. -file#:#version_uploaded#:#Version Uploaded -file#:#versionname#:#Title fils#:#add_upload_policy#:#Add Policy fils#:#edit_upload_policy#:#Edit Policy fils#:#file_services#:#File Services